` 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.
+
+#### Persisting the Token Across Restarts
+
+By default the token lives in memory only, so a process that restarts has to run the device flow again. Pass a `TokenStore` to persist it; the restarted process then resumes from the saved refresh token — a silent call to the token endpoint — instead of prompting the user again:
+
+```java
+import io.questdb.client.cutlass.auth.FileTokenStore;
+
+try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
+ "https://questdb.example.com:9000",
+ new OidcDeviceAuth.DiscoveryOptions().tokenStore(FileTokenStore.atDefaultLocation()))) {
+ auth.signIn(); // prompts the first time; after a restart it refreshes silently from the saved token
+}
+```
+
+`FileTokenStore.atDefaultLocation()` writes one file per OIDC configuration under `${user.home}/.questdb/oidc-tokens/` (override the directory with the `questdb.client.oidc.token.store.dir` system property). The file name is a hash of the endpoints, client id, scope, audience and groups-in-token mode — the *configuration*, not the person who signed in through it, since none of those fields names a subject. Entries for different servers, providers or client configurations therefore stay separate, but **two people signing in through the same configuration share one entry**: whoever signs in last overwrites the previous token, so a store represents a single active login. If more than one application user has to be signed in at the same time, give each their own store — `FileTokenStore.at(dir)` on a per-user directory, or a per-user `questdb.client.oidc.token.store.dir` — rather than relying on the file name to separate them. The default location is already per OS user, so this only arises when one OS user (a shared service account, a multi-tenant process) signs in as several people. After a restart, `getToken()` also works as the first call — no explicit `signIn()` needed — which suits a long-lived `Sender` built with `httpTokenProvider(auth::getToken)`. `clearCache()` removes the persisted entry and forces a fresh sign-in next time.
+
+A store read that *throws* — an unreadable file after a `chmod` or a uid change in a container, `EIO`/`ESTALE` on an NFS home — is not fatal and does not disable persistence for the life of the process, but it is not retried on every call either, since `getToken()` would otherwise pay a blocking file open and a `WARN` line per ILP flush, forever. The first failure is retried immediately, so a one-shot fault (notably a carried interrupt flag, which makes the channel underneath `FileTokenStore` throw on a thread that merely carries it) recovers on the next call; each consecutive failure after that backs off 5 seconds, doubling to a 60 second cap. A store that simply has nothing to return is unaffected — `load` reports that by returning `null` rather than by throwing, and the client stops asking.
+
+The token is stored as **plaintext JSON protected by file permissions** — `0600` file, `0700` directory on POSIX systems (Linux, macOS), the same approach `gcloud`, `aws` and `gh` take. On Windows these POSIX permissions cannot be enforced, so the file currently relies on the user-profile directory's default ACL (owner-only ACL hardening is a follow-up); the client logs a one-line warning through SLF4J at `WARN` the first time it cannot enforce them (the library ships `slf4j-api` only, so this - and every other client warning - is discarded unless your application supplies an SLF4J binding). Enabling persistence therefore writes a long-lived refresh token to disk: anyone who can read the file holds a credential until it expires or is revoked. To encrypt it at rest, supply your own `TokenStore` (backed by an OS keychain or a secrets manager) instead of `FileTokenStore`. A persisted file is treated as untrusted input on load, but it is **not cryptographically authenticated** — there is no MAC or signature over its contents. Anyone who can write the file can therefore substitute a well-formed entry of their own, and the client will adopt it and present those tokens: the file permissions, not the file format, are what protect it. What the load path rejects is corruption and mix-ups rather than forgery — an oversized, malformed or unparseable file; an entry whose recorded client id, endpoints, scope, audience or groups-in-token mode does not match the identity being loaded; an entry carrying no usable token; and a token with control or non-ASCII characters, which is never placed on the wire — with the recorded expiry and lifetime clamped rather than trusted. In each case the client falls back to a refresh or an interactive sign-in. On POSIX the container is checked too: if the directory was writable by other local users, it is tightened back to `0700` and **every** entry in it is discarded — not just the one being read, since the tightening is what destroys the evidence, so anything left behind would look protected to the next load. Each identity then signs in again. Inside those permissions, though, the client cannot tell a planted credential from its own.
+
+`FileTokenStore` is safe to share between processes that sign in as the same identity: each update is written atomically (so a concurrent reader never sees a half-written credential), and when the identity provider rotates the refresh token on each refresh, the read-refresh-write is serialized across processes with a lock file so they do not race each other into an unnecessary re-prompt. The lock file's staleness is judged by its modification time, so this coordination assumes the processes share a clock — a single machine, or machines with synchronized clocks; under significant clock skew (for example a store directory on NFS shared across hosts) a live lock can be mis-judged stale or a dead one never expire. `clearCache()` removes the persisted entry under the same lock, but across processes it is best-effort: a peer that still holds a live in-memory token may legitimately re-persist afterwards (it always forces a fresh sign-in for the calling process).
+
### Explicit Timestamps
```java
diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java
new file mode 100644
index 000000000..cf28bbf50
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java
@@ -0,0 +1,109 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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} or pooled {@link QuestDB} connection on
+ * demand, so a provider returning a freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken}
+ * - keeps long-lived ingest and query connections authenticated as the token rotates, without
+ * rebuilding them. An HTTP sender calls {@link #getToken()} as it builds each request; WebSocket
+ * ingest and query clients call it once per connection handshake, on the initial connect and again
+ * on every reconnect.
+ *
+ * {@link #getToken()} runs on HTTP flush and pooled connection/reconnection paths. Different pooled
+ * connections may call it concurrently, so implementations must be thread-safe. 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; a provider that coordinates a shared token store across processes
+ * (for example {@code OidcDeviceAuth} with a {@code FileTokenStore}) may add a brief, bounded wait to
+ * acquire that store's cross-process lock before such a refresh, which still counts as a quick silent
+ * refresh. Note that "quick" bounds the interactive wait, not the network: the silent refresh is a
+ * synchronous HTTP round-trip to the token endpoint, and its connection phase (DNS, TCP connect, TLS)
+ * is bounded by the client timeout as well for the bundled {@code OidcDeviceAuth}, leaving only DNS
+ * resolution to the OS. A provider that builds its own HTTP client should bound its connect and TLS
+ * handshake likewise, or a black-holed token endpoint stalls a flush for the OS connect timeout. An exception from {@link #getToken()} fails the
+ * in-flight flush (HTTP) or the connection attempt (WebSocket).
+ *
+ * @see QuestDB#connect(CharSequence, HttpTokenProvider)
+ * @see QuestDBBuilder#httpTokenProvider(HttpTokenProvider)
+ * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider)
+ */
+@FunctionalInterface
+public interface HttpTokenProvider {
+ /**
+ * Validates a token returned by {@link #getToken()} before the client writes it into an
+ * {@code Authorization: Bearer} header.
+ *
+ * Callers must pass a value that cannot change between this check and the write that follows it.
+ * {@code getToken()} may return a reused buffer, so validating the provider's sequence and then
+ * re-reading it to build the header reads it twice: a mutation in between passes the check and
+ * splices the mutated bytes - a CR/LF among them - into the header. Snapshot with
+ * {@link Object#toString()} first, then validate and send the snapshot. Every call site in this
+ * library does.
+ *
+ * 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 client
+ * adds it). Must not return null or empty, and must contain only printable ASCII (no control or
+ * non-ASCII characters) - the client splices the value verbatim into an {@code Authorization:
+ * Bearer} header and rejects a token that violates this (see {@link #validateToken(CharSequence)}).
+ *
+ * Returning a reused, mutable {@link CharSequence} - the idiomatic zero-allocation style - is
+ * supported and expected: the client re-validates every pulled token rather than trusting instance
+ * identity, so a buffer whose contents changed since the last call is checked again. What an
+ * implementation must not do is mutate a sequence it has already returned while the client is
+ * still reading it. The client snapshots each returned value before validating it, so a
+ * concurrent mutation cannot slip past the check into the header; an implementation that mutates
+ * mid-call is nonetheless racing with a reader and may see its own token dropped for the one the
+ * snapshot captured. Mutate between calls, not during one.
+ *
+ * @return the current HTTP authentication token
+ */
+ CharSequence getToken();
+}
diff --git a/core/src/main/java/io/questdb/client/QuestDB.java b/core/src/main/java/io/questdb/client/QuestDB.java
index a4c7cb03f..cf669a166 100644
--- a/core/src/main/java/io/questdb/client/QuestDB.java
+++ b/core/src/main/java/io/questdb/client/QuestDB.java
@@ -78,6 +78,37 @@ static QuestDB connect(CharSequence configurationString) {
return builder().fromConfig(configurationString).build();
}
+ /**
+ * Connects with a token supplied on demand for every initial WebSocket
+ * upgrade and reconnect. Use this overload for rotating credentials such
+ * as an OIDC device-flow token ({@code auth::getToken}); unlike a fixed
+ * {@code token=} value in the configuration string, the provider is queried
+ * again whenever either the ingest or query pool establishes a connection.
+ *
+ * The caller owns the provider and anything it captures. In particular,
+ * this handle does not close an {@code OidcDeviceAuth}; declare/close the
+ * {@code QuestDB} handle before closing the auth object. The provider may be
+ * called concurrently by different pooled connections and must be
+ * thread-safe. Interactive sign-in must happen before this call when the
+ * pools connect eagerly because token providers run on connection paths and
+ * must not prompt.
+ *
+ * The configuration must not contain {@code token}, {@code username}, or
+ * {@code password}; those fixed credentials are mutually exclusive with a
+ * token provider.
+ *
+ * @param configurationString a {@code ws}/{@code wss} config string
+ * @param tokenProvider supplies the current bearer token without the
+ * {@code "Bearer "} prefix
+ * @return a connected QuestDB handle
+ */
+ static QuestDB connect(CharSequence configurationString, HttpTokenProvider tokenProvider) {
+ return builder()
+ .fromConfig(configurationString)
+ .httpTokenProvider(tokenProvider)
+ .build();
+ }
+
/**
* Borrows a {@link Query} handle from the pool. The caller MUST call
* {@link Query#close()} on the returned instance to release it back to the
diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java
index be18bfbec..e846ad129 100644
--- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java
+++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java
@@ -73,6 +73,7 @@ public final class QuestDBBuilder {
private BackgroundDrainerListener drainerListener;
private SenderErrorHandler errorHandler;
private long housekeeperIntervalMillis = UNSET;
+ private HttpTokenProvider httpTokenProvider;
private String config;
private long idleTimeoutMillis = UNSET;
private long maxLifetimeMillis = UNSET;
@@ -186,6 +187,11 @@ public QuestDB build() {
}
ConfigString cs = ConfigString.parse(config);
ConfigView view = new ConfigView(cs);
+ if (httpTokenProvider != null
+ && (view.has("token") || view.has("username") || view.has("password"))) {
+ throw new IllegalArgumentException(
+ "httpTokenProvider cannot be combined with token, username, or password in the configuration");
+ }
// Validate the single cluster config exactly as both pools will, but
// without connecting: the full Sender parse plus validateParameters
// (ingress value keys are registry-STRING, so only the real parse
@@ -229,6 +235,7 @@ public QuestDB build() {
maxLifetimeMillis,
housekeeperIntervalMillis,
queryCloseTimeoutMillis,
+ httpTokenProvider,
errorHandler,
connectionListener,
drainerListener
@@ -300,6 +307,34 @@ public QuestDBBuilder housekeeperIntervalMillis(long millis) {
return this;
}
+ /**
+ * Supplies the bearer token on demand to every pooled ingest and query
+ * connection. The provider is queried for each initial WebSocket upgrade
+ * and reconnect, so a rotating token such as
+ * {@code OidcDeviceAuth::getToken} remains usable for the lifetime of this
+ * handle. Different pooled connections may call it concurrently, so it
+ * must be thread-safe.
+ *
+ * The provider runs on connection/reconnection paths and must not perform
+ * interactive sign-in. Call {@code OidcDeviceAuth.signIn()} before
+ * {@link #build()} when no persisted token is available. The builder and
+ * resulting {@link QuestDB} handle do not own or close the provider.
+ *
+ * Mutually exclusive with {@code token}, {@code username}, and
+ * {@code password} in the configuration string.
+ *
+ * @param tokenProvider supplies the current bearer token without the
+ * {@code "Bearer "} prefix
+ * @return this instance for method chaining
+ */
+ public QuestDBBuilder httpTokenProvider(HttpTokenProvider tokenProvider) {
+ if (tokenProvider == null) {
+ throw new IllegalArgumentException("httpTokenProvider must not be null");
+ }
+ this.httpTokenProvider = tokenProvider;
+ return this;
+ }
+
/**
* How long a connection may remain idle in the pool before the
* housekeeper closes it. {@code minSize} is always respected -- the pool
diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java
index bfef51898..645d7b254 100644
--- a/core/src/main/java/io/questdb/client/Sender.java
+++ b/core/src/main/java/io/questdb/client/Sender.java
@@ -77,6 +77,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.
@@ -1091,6 +1092,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
@@ -1449,7 +1451,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) {
@@ -1463,7 +1465,7 @@ public Sender build() {
? DEFAULT_WS_AUTO_FLUSH_INTERVAL_NANOS
: TimeUnit.MILLISECONDS.toNanos(autoFlushIntervalMillis);
- String wsAuthHeader = buildWebSocketAuthHeader();
+ Supplier wsAuthHeader = buildWebSocketAuthHeader();
ClientTlsConfiguration wsTlsConfig = null;
if (tlsEnabled) {
@@ -1689,7 +1691,7 @@ public Sender build() {
// still rescue, so build() waits for that verdict rather than pre-judging it.
while (connected == null) {
try {
- connected = QwpWebSocketSender.connect(
+ connected = QwpWebSocketSender.connectWithCredentialSupplier(
wsEndpoints,
wsTlsConfig,
actualAutoFlushRows,
@@ -2287,6 +2289,9 @@ 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");
}
@@ -2294,6 +2299,65 @@ public LineSenderBuilder httpToken(String 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)}.
+ *
+ * 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 it depends on whether the initial connect is eager.
+ * With an EAGER initial connect (the default, and any {@code initial_connect_retry} other than
+ * {@code async}) the handshake runs during {@code build()} and queries the provider once for it;
+ * under {@code lazy_connect=true} - or {@code initial_connect_retry=async} - the ingest side connects
+ * asynchronously, so {@code build()} pulls nothing and a provider failure surfaces through the error
+ * inbox rather than from {@code build()}. Either way the provider is queried 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 in
+ * mechanism but both keep the producer alive across a sustained token outage: over HTTP a failed pull
+ * leaves the request token-pending and is retried on the next row; over WebSocket an EAGER initial
+ * handshake fails fast when no token can be obtained, after which a pull that keeps failing on later
+ * reconnects is retried indefinitely, with the buffered rows held in store-and-forward, until a token
+ * is available again. A token outage does not terminate a running WebSocket sender, just as a
+ * persistent transport reconnect failure does not (store-and-forward Invariant B).
+ *
+ * Over HTTP the token is pulled once per request and written into the request buffer ahead of the
+ * buffered rows, so a token refresh is picked up on the next new batch after a successful flush. A
+ * failed flush preserves the buffer - token included - for a later retry and re-sends it verbatim
+ * rather than re-pulling the token, so a flush that keeps failing until the already-pulled token
+ * expires is then rejected (for example a {@code 401}); recover by discarding the buffered rows (close
+ * and rebuild the sender) so the next request pulls a fresh token.
+ *
+ * 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, and over WebSocket under {@code lazy_connect=true}
+ * (or {@code initial_connect_retry=async}), where nothing is pulled at build time either. What does
+ * require a token up front is an EAGER WebSocket connect: its initial handshake pulls one during
+ * {@code build()}, so that {@code build()} (or, over HTTP, the first row) fails when none can be
+ * obtained. 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.
*
@@ -2319,6 +2383,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;
@@ -3357,13 +3424,34 @@ private void appendAddress(String host, int port) {
ports.add(port);
}
- private String buildWebSocketAuthHeader() {
+ private Supplier buildWebSocketAuthHeader() {
+ // A constant credential goes through fixedAuthHeader, not a bare lambda: the tag is what lets
+ // the store-and-forward drainer tell a permanently-wrong password from a rotating token that a
+ // fresh pull can repair, and so decide whether a 401 may quarantine an orphan slot for good.
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 QwpWebSocketSender.fixedAuthHeader(header);
}
if (httpToken != null) {
- return "Bearer " + httpToken;
+ String header = "Bearer " + httpToken;
+ return QwpWebSocketSender.fixedAuthHeader(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 () -> {
+ // snapshot before validating: the concatenation below re-reads the sequence, and a
+ // provider is free to reuse a mutable buffer, so validating the live sequence checks
+ // bytes the header need not carry. See HttpTokenProvider.validateToken.
+ CharSequence pulled = provider.getToken();
+ CharSequence token = pulled == null ? null : pulled.toString();
+ HttpTokenProvider.validateToken(token);
+ return "Bearer " + token;
+ };
}
return null;
}
@@ -4343,6 +4431,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");
}
@@ -4368,6 +4459,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");
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java
new file mode 100644
index 000000000..d31050929
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java
@@ -0,0 +1,102 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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 java.awt.Desktop;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * Opens a verification URL in the local default browser, best-effort. Kept separate from
+ * {@link DeviceCodePrompt} so a runtime without the {@code java.desktop} module fails only when
+ * {@link DeviceCodePrompt#openBrowser()} is actually used, not when the interface loads.
+ */
+final class BrowserLauncher {
+
+ // System property to disable the automatic browser launch (default: enabled). Set to "false" on a
+ // host that must never pop a browser - a server, automation, CI - or to keep a test run headless.
+ private static final String OPEN_BROWSER_PROPERTY = "questdb.client.oidc.open.browser";
+
+ private BrowserLauncher() {
+ }
+
+ /**
+ * Whether the automatic browser launch is enabled - the {@code questdb.client.oidc.open.browser}
+ * kill-switch (default enabled; set to {@code false} to disable). Package-private so a test can assert
+ * the kill-switch without triggering a real browser launch, which is otherwise unobservable.
+ */
+ static boolean isBrowserOpenEnabled() {
+ return Boolean.parseBoolean(System.getProperty(OPEN_BROWSER_PROPERTY, "true"));
+ }
+
+ /**
+ * Opens {@code url} in the default browser when it is an http(s) URL and a desktop browser is
+ * available. Does nothing on a headless JVM, for a non-http(s) URL, on a launch failure, or when the
+ * {@code questdb.client.oidc.open.browser} system property is set to {@code false}. May throw a
+ * {@link LinkageError} when the {@code java.desktop} module is absent from the runtime; the caller
+ * treats that as "no browser available".
+ */
+ static void open(String url) {
+ if (!isBrowserOpenEnabled()) {
+ return;
+ }
+ URI uri = safeHttpUri(url);
+ if (uri == null) {
+ return;
+ }
+ try {
+ if (Desktop.isDesktopSupported()) {
+ Desktop desktop = Desktop.getDesktop();
+ if (desktop.isSupported(Desktop.Action.BROWSE)) {
+ desktop.browse(uri);
+ }
+ }
+ } catch (Exception ignore) {
+ // a headless display, a missing default browser or a security restriction must never
+ // break sign-in: the verification URL and code are already shown to the user
+ }
+ }
+
+ /**
+ * Returns {@code url} as a {@link URI} only when it parses and uses an http(s) scheme, else
+ * {@code null}. The verification URL is an untrusted identity-provider response field; the
+ * allowlist stops a javascript:, data: or file: scheme from reaching the OS browser handler.
+ */
+ static URI safeHttpUri(String url) {
+ if (url == null) {
+ return null;
+ }
+ try {
+ URI uri = new URI(url);
+ String scheme = uri.getScheme();
+ if (scheme != null && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
+ return uri;
+ }
+ return null;
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java
new file mode 100644
index 000000000..f398ebfd1
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java
@@ -0,0 +1,90 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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 to type and the URL
+ * to type it at. A {@link DeviceCodePrompt} receives this object and shows it to the user.
+ *
+ * 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 seconds the {@link #getUserCode() user code} stays valid.
+ */
+ public int getExpiresInSeconds() {
+ return expiresInSeconds;
+ }
+
+ /**
+ * @return minimum seconds the client must wait between polls.
+ */
+ public int getIntervalSeconds() {
+ return intervalSeconds;
+ }
+
+ /**
+ * @return the code the user enters at the {@link #getVerificationUri() verification URL}.
+ */
+ public String getUserCode() {
+ return userCode;
+ }
+
+ /**
+ * @return the URL the user opens to authorize the device.
+ */
+ public String getVerificationUri() {
+ return verificationUri;
+ }
+
+ /**
+ * @return a URL with the user code already embedded, so the user need not type it, or
+ * {@code null} when the identity provider does not supply one.
+ */
+ public String getVerificationUriComplete() {
+ return verificationUriComplete;
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java
new file mode 100644
index 000000000..314ddb748
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java
@@ -0,0 +1,112 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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 (same machine or phone) and enters the code. {@link OidcDeviceAuth} calls this once
+ * per interactive sign-in, just before polling the token endpoint.
+ *
+ * The default is {@link #openBrowser()}: it prints instructions to {@code System.out} and also tries
+ * to open the verification URL in the local default browser when one is available. Use
+ * {@link #SYSTEM_OUT} to print only, or supply your own to render the challenge elsewhere, e.g. a
+ * clickable link or a QR code in a notebook.
+ */
+@FunctionalInterface
+public interface DeviceCodePrompt {
+
+ /**
+ * Prints the sign-in instructions to {@code System.out} as display-safe text, without opening a browser.
+ * The default prompt is {@link #openBrowser()}; use this to opt out of the browser launch.
+ */
+ 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);
+ };
+
+ /**
+ * Returns a prompt that prints the challenge like {@link #SYSTEM_OUT} and then also tries to open
+ * the verification URL in the local default browser. The browser open is best-effort: it is
+ * skipped on a headless JVM, on a runtime without the {@code java.desktop} module, or for a
+ * non-http(s) URL, and never prevents sign-in. Intended for a local terminal; on a remote or
+ * headless host the printed URL and code remain the way in. This is the default prompt when none
+ * is configured.
+ *
+ * {@code io.questdb.client} declares {@code requires static java.desktop}, so the module is a
+ * compile-time dependency only and its absence at run time is just another reason to skip the
+ * browser. One consequence is worth knowing: a static requires is not followed during module
+ * resolution, so an application that runs this client as an EXPLICIT module gets the browser launch
+ * only when {@code java.desktop} is in its module graph anyway - because it requires it, or because
+ * the launch adds it ({@code --add-modules java.desktop}). Class-path applications are unaffected,
+ * {@code java.desktop} being resolved by default there.
+ *
+ * @return a prompt that prints the challenge and opens the verification URL in a browser
+ */
+ static DeviceCodePrompt openBrowser() {
+ return openBrowser(SYSTEM_OUT);
+ }
+
+ /**
+ * Like {@link #openBrowser()}, but renders the challenge with {@code delegate} before opening the
+ * browser, instead of the built-in {@code System.out} printer.
+ *
+ * @param delegate the prompt that shows the challenge to the user
+ * @return a prompt that runs {@code delegate} and then opens the verification URL in a browser
+ */
+ static DeviceCodePrompt openBrowser(DeviceCodePrompt delegate) {
+ return challenge -> {
+ delegate.promptUser(challenge);
+ String url = challenge.getVerificationUriComplete() != null
+ ? challenge.getVerificationUriComplete()
+ : challenge.getVerificationUri();
+ try {
+ BrowserLauncher.open(url);
+ } catch (LinkageError ignore) {
+ // the java.desktop module is absent from this runtime; the printed URL and code remain
+ }
+ };
+ }
+
+ /**
+ * Shows the challenge to the user. Must return quickly; 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);
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java
new file mode 100644
index 000000000..c1e110479
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java
@@ -0,0 +1,1577 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.json.JsonException;
+import io.questdb.client.cutlass.json.JsonLexer;
+import io.questdb.client.cutlass.json.JsonParser;
+import io.questdb.client.std.Chars;
+import io.questdb.client.std.Numbers;
+import io.questdb.client.std.NumericException;
+import io.questdb.client.std.Os;
+import io.questdb.client.std.str.DirectUtf8Sink;
+import io.questdb.client.std.str.StringSink;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * The default {@link TokenStore}: one plaintext JSON file per OIDC configuration under a directory, with
+ * the refresh token protected at rest by file permissions (0600 file, 0700 directory) rather than by
+ * encryption. This matches what {@code gcloud}, {@code aws} and {@code gh} do; for encryption at rest,
+ * supply a {@link TokenStore} backed by an OS keychain or a secrets manager instead.
+ *
+ * The default location is {@code ${user.home}/.questdb/oidc-tokens/}, overridable with the
+ * {@code questdb.client.oidc.token.store.dir} system property. The file name is
+ * {@code .json}, so several configurations coexist and the name leaks neither the
+ * endpoint nor the client id. The on-disk format (file name, JSON schema, write protocol, lock-file
+ * protocol) is a deliberately language-neutral contract so other QuestDB clients can share the file.
+ *
+ * One store, one active login. {@link TokenStoreKey} names a CONFIGURATION - client id, endpoints,
+ * scope, audience, groups-in-token mode - and no field of it names a subject, so two people signing in
+ * through the same configuration address the same file and the later sign-in overwrites the earlier one.
+ * Separate application users need separate stores ({@link #at(Path)} on a per-user directory, or a per-user
+ * {@code questdb.client.oidc.token.store.dir}), not a reliance on the key to tell them apart. The default
+ * location is per OS user already, so this only arises inside one OS user - a shared service account, or a
+ * process signing in on behalf of several people.
+ *
+ * Not authenticated. The file carries no MAC or signature, so {@link #load} cannot distinguish a
+ * planted credential from its own: anyone able to WRITE the file can substitute a well-formed entry that
+ * this store will adopt and the caller will present. Permissions are the control, not the format. What load
+ * does reject is corruption and mix-ups - an oversized, malformed or unparseable file, an entry whose
+ * recorded identity fields do not match the key being loaded, an entry with no usable token, a token
+ * carrying control or non-ASCII characters - with the recorded expiry and lifetime clamped rather than
+ * trusted, and (on POSIX) an entry discarded outright when the directory is writable by other local users.
+ *
+ * Integrity (always). {@link #save} writes a sibling temp file then atomically renames it over the
+ * target, so a crash or an overlapping reader - in any process or language - sees the whole old or whole
+ * new file, never a torn credential.
+ *
+ * Rotating refresh tokens (Layer 2). {@link #inLock} serialises the read-refresh-write of a token
+ * refresh across processes with an {@code O_CREAT|O_EXCL} lock file ({@code .lock}) - not an OS
+ * advisory lock, which a Java {@code FileLock} and a Python {@code flock} cannot reliably share. It steals
+ * a stale lock left by a crashed holder, and degrades to running without the lock (Layer 1 still protects
+ * integrity) rather than stall a sign-in if it cannot acquire one.
+ *
+ * That degrade has a residual worth understanding: if a peer's refresh genuinely outlasts the acquire
+ * budget (a slow or stalled IdP), or its lock is judged stale and stolen mid-refresh, two processes can
+ * POST the same parent refresh token concurrently. On an IdP that does not detect refresh-token reuse this
+ * costs only a redundant refresh; on one that DOES (for example Auth0's default), reusing one parent token
+ * twice can revoke the whole token family, forcing every process to re-run the interactive device flow -
+ * which, for a headless {@code getToken()} consumer with no interactive fallback, is a hard failure until a
+ * human re-signs in. If that matters, widen the acquire budget / staleness window, or back the store with a
+ * keychain or secrets manager instead.
+ *
+ * The store never writes a token value into a log or an exception message; only file paths and IO error
+ * kinds may surface.
+ */
+public final class FileTokenStore implements TokenStore {
+ public static final String TOKEN_STORE_DIR_PROPERTY = "questdb.client.oidc.token.store.dir";
+ // wait this long for the per-identity lock before giving up and running without it (Layer 1 still
+ // guards integrity). Kept short because getToken() can take this lock on the latency-sensitive flush
+ // path: a real refresh round-trip is sub-second, so a peer not done within this budget is treated as
+ // too slow and we degrade to a lock-free refresh rather than stall the caller
+ private static final long DEFAULT_LOCK_ACQUIRE_BUDGET_MILLIS = 3_000L;
+ // treat a lock older than this as abandoned by a crashed holder and steal it. Must stay comfortably
+ // above the longest a live holder can hold it (one refresh under the lock) so a live holder is never
+ // stolen from. That refresh runs send + await + parse, plus a body drain on a parse failure, each
+ // separately bounded by the client's HTTP timeout (so up to ~4x it; OidcDeviceAuth caps that timeout at
+ // 120s, hence ~480s), PLUS the connection phase (DNS + TCP connect + TLS handshake), which the HTTP
+ // timeout does NOT bound and which the OS bounds instead (a black-holed connect is ~tcp-connect-timeout,
+ // commonly ~2 minutes on Linux). This 10-minute window leaves ample headroom above ~480s + a typical
+ // connection stall; a pathological DNS/connection hang longer than that headroom can still let a peer
+ // steal a live holder's lock mid-refresh, degrading to a concurrent refresh of the same parent refresh
+ // token: a redundant refresh on most IdPs, but on a reuse-detecting one (e.g. Auth0 default) a possible
+ // token-family revocation and re-prompt / headless hard-failure (see the class javadoc residual note)
+ private static final long DEFAULT_LOCK_STALE_MILLIS = 600_000L;
+ private static final FileAttribute> DIR_ATTRS =
+ PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"));
+ // the same owner-only directory permissions as DIR_ATTRS, in the form setPosixFilePermissions wants, so
+ // ensureDirectory can re-assert them on a directory that already exists with looser permissions
+ private static final Set DIR_PERMS = PosixFilePermissions.fromString("rwx------");
+ // steal an empty/unstamped lock once it has existed at least this long. A validly held lock always
+ // carries an owner stamp (acquireLock stamps it immediately after the exclusive create); an empty lock is
+ // therefore either a peer momentarily between its create and its stamp - microseconds, far below this
+ // grace - or one a holder abandoned by crashing in that tiny window. Stealing on this short grace instead
+ // of the full staleness window keeps a post-crash empty lock from wedging peers (into lock-free refreshes)
+ // for the whole staleness window, while the grace stays well above the create->stamp gap so a peer
+ // mid-stamp is never pre-empted (which would force the rightful holder to degrade)
+ private static final long EMPTY_LOCK_STEAL_GRACE_MILLIS = 5_000L;
+ private static final FileAttribute> FILE_ATTRS =
+ PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"));
+ // Length of the identity fingerprint every file this store writes is named after: TokenStoreKey.hash()
+ // is a SHA-256 rendered as lowercase hex, so 64 characters. Used to tell this store's own files apart
+ // from whatever else shares the directory - see discardUntrustedDirectoryContents().
+ private static final int HASH_NAME_LENGTH = 64;
+ private static final char[] HEX = "0123456789abcdef".toCharArray();
+ private static final int JSON_LEXER_CACHE_SIZE = 1024;
+ private static final int JSON_LEXER_MAX_VALUE_BYTES = 1 << 20;
+ private static final long LOCK_POLL_SLICE_MILLIS = 50L;
+ private static final Logger LOG = LoggerFactory.getLogger(FileTokenStore.class);
+ // reject a token file larger than this; a real entry is a few KB even with a group-laden id token, so
+ // anything past this is corrupt or hostile and is not read into memory
+ private static final long MAX_FILE_BYTES = 1 << 20;
+ // upper bound on the configurable lock acquire budget. getToken() can take this lock on the
+ // latency-sensitive flush path, so a caller-supplied budget is kept short: a real peer refresh is
+ // sub-second, and bounding the wait stops a misconfigured budget from stalling a flush before it degrades
+ // to a lock-free refresh (Layer 1 still guards integrity). Stays well below DEFAULT_LOCK_STALE_MILLIS so a
+ // waiter degrades long before it could begin stealing live locks.
+ private static final long MAX_LOCK_ACQUIRE_BUDGET_MILLIS = 30_000L;
+ // reject a lock file larger than this before reading it: the .lock file sits in the same
+ // attacker-writable directory as the token file, and a real owner stamp (millis + UUID) is a few dozen
+ // bytes, so anything past this cap is corrupt or hostile and is not read into memory
+ private static final int MAX_LOCK_FILE_BYTES = 1 << 12;
+ // Serializes same-identity critical sections WITHIN this JVM. Two OidcDeviceAuth instances for one
+ // identity in a single process (e.g. an ILP Sender and a QwpQueryClient) have separate instance locks, so
+ // only this shared lock stops them running the read-refresh-write concurrently and double-POSTing the same
+ // parent refresh token - which a reuse-detecting IdP revokes the whole token family for. The cross-process
+ // file lock's lock-free degrade must not license an intra-process race, so this in-process lock is taken
+ // first and is never subject to that degrade.
+ //
+ // Keyed on the identity fingerprint, with one entry per identity that currently has a holder or a
+ // waiter and nothing left behind once the last of them leaves. TokenStoreKey is public and inLock() is
+ // public API, so how many distinct identities a process mints is the caller's business - one per end
+ // user in a multi-tenant service is a perfectly ordinary shape - and an entry per identity EVER SEEN,
+ // which an unpruned map gives, roots a 64-char hash plus a lock for the life of the JVM. Retiring on
+ // the last release bounds the map by CONCURRENT identities instead, which is bounded by live threads.
+ //
+ // A fixed stripe table also bounds it, and was tried, but over-serializing is not the free trade it
+ // looks: this lock is held across a whole token-endpoint round trip while the caller also holds its
+ // OidcDeviceAuth instance lock, and the acquire has no budget. Two unrelated identities landing on one
+ // stripe therefore do not merely "wait for each other" - one tenant's ILP flush blocks on another
+ // tenant's stalled refresh for that holder's entire worst case, which getToken() sizes at four times
+ // httpTimeoutMillis plus an OS connect stall, and every other caller on the blocked instance fails
+ // meanwhile. That also made OidcDeviceAuth.getToken()'s "two instances sharing ONE IDENTITY" contract
+ // untrue. Per-identity entries serialize exactly the same-identity pairs the double-POST rule needs and
+ // nothing else, so the contract holds as written.
+ //
+ // compute()/computeIfPresent() apply their function atomically under the bin lock, so `users` needs no
+ // synchronization of its own and retirement has no race: an arriving thread cannot observe an entry
+ // that a departing one is removing.
+ private static final ConcurrentHashMap PROCESS_LOCKS = new ConcurrentHashMap<>();
+ // Windows can fail the atomic token-file rename with a transient AccessDeniedException (a sharing violation)
+ // when a concurrent reader in any process holds the target open; retry the rename this many times on a short
+ // backoff before giving up, so a routine read/write overlap does not needlessly degrade persistence. Kept
+ // small - persistence is best-effort and the in-memory token is valid regardless.
+ private static final int REPLACE_MAX_ATTEMPTS = 5;
+ private static final long REPLACE_RETRY_SLEEP_MILLIS = 20L;
+ private static final int SCHEMA_VERSION = 1;
+ // set once if the platform cannot enforce owner-only POSIX permissions on the token files (e.g. Windows),
+ // so the at-rest protection falls back to the directory's inherited ACL; warns the user exactly once
+ // (compareAndSet, so a race between two threads still prints a single warning)
+ private static final AtomicBoolean warnedNoPosixPerms = new AtomicBoolean();
+ private static final AtomicBoolean warnedTightenedStoreDir = new AtomicBoolean();
+ private static final AtomicBoolean warnedUnprotectedStoreDir = new AtomicBoolean();
+ private final Path directory;
+ private final long lockAcquireBudgetMillis;
+ // Namespaces this store's entries in PROCESS_LOCKS, so two stores over DIFFERENT directories never
+ // contend even when they share one OIDC configuration. Normalized once here rather than per acquire:
+ // "a" and "./a" must not mint two locks over one directory, which would be the dangerous direction.
+ // toAbsolutePath().normalize() rather than toRealPath(): the directory may not exist yet (the store
+ // creates it lazily), and a key that changed once it did would be worse than one that ignores symlinks.
+ // Two stores reaching one directory through different symlinks therefore still get separate in-process
+ // locks; the cross-process lock file, which they DO share, remains the guard for that shape.
+ private final String lockNamespace;
+ private final long lockStaleMillis;
+
+ public FileTokenStore(Path directory) {
+ this(directory, DEFAULT_LOCK_ACQUIRE_BUDGET_MILLIS, DEFAULT_LOCK_STALE_MILLIS);
+ }
+
+ /**
+ * Advanced constructor exposing the cross-process lock-file timings used by
+ * {@link #inLock(TokenStoreKey, CriticalSection)}. Most callers should use {@link #FileTokenStore(Path)}
+ * or the factories, which apply sensible defaults.
+ *
+ * @param directory the directory to hold the token files
+ * @param lockAcquireBudgetMillis how long {@code inLock} waits to acquire a peer's lock before degrading
+ * to a lock-free refresh rather than stalling a sign-in. Must be positive
+ * and at most 30_000 (30s): {@code getToken()} can wait it out on the
+ * latency-sensitive flush path, so it is kept short
+ * @param lockStaleMillis a lock older than this is treated as abandoned by a crashed holder and
+ * stolen. It MUST exceed the longest a live holder can hold the lock, which
+ * is the under-lock refresh PLUS the connection phase that precedes it: the
+ * refresh runs send + await + parse plus a body drain, each bounded by the
+ * {@code OidcDeviceAuth} httpTimeoutMillis (so up to ~4x it, ~480s at the
+ * 120s timeout cap), but establishing the connection - DNS resolution, the
+ * TCP connect, and the TLS handshake - is NOT bounded by httpTimeoutMillis;
+ * the OS bounds it instead (a black-holed connect runs to the OS TCP-connect
+ * timeout, commonly ~2 minutes). Size this window above ~4x httpTimeoutMillis
+ * plus a generous connection-stall allowance, or a peer can judge a live but
+ * connection-stalled holder stale and steal its lock mid-refresh, reopening
+ * the cross-process refresh race this lock exists to prevent. The store
+ * cannot see the client's timeout, so sizing this correctly is the caller's
+ * responsibility; the default is 600_000 (~480s worst-case refresh plus
+ * ample headroom for a typical connection stall).
+ */
+ public FileTokenStore(Path directory, long lockAcquireBudgetMillis, long lockStaleMillis) {
+ if (directory == null) {
+ throw new OidcAuthException("the token store directory is required");
+ }
+ if (lockAcquireBudgetMillis <= 0) {
+ throw new OidcAuthException("the token store lockAcquireBudgetMillis must be positive");
+ }
+ if (lockAcquireBudgetMillis > MAX_LOCK_ACQUIRE_BUDGET_MILLIS) {
+ // getToken() can wait out this budget on the latency-sensitive flush path, so an unbounded value
+ // would let a misconfiguration stall a flush; keep it short - it degrades to a lock-free refresh
+ throw new OidcAuthException()
+ .put("the token store lockAcquireBudgetMillis must not exceed ").put(MAX_LOCK_ACQUIRE_BUDGET_MILLIS);
+ }
+ // a non-positive staleness window makes every freshly created lock look abandoned, so acquirers would
+ // steal each other's live locks; keep it well above one refresh round-trip (see the default)
+ if (lockStaleMillis <= 0) {
+ throw new OidcAuthException("the token store lockStaleMillis must be positive");
+ }
+ this.directory = directory;
+ this.lockNamespace = directory.toAbsolutePath().normalize().toString();
+ this.lockAcquireBudgetMillis = lockAcquireBudgetMillis;
+ this.lockStaleMillis = lockStaleMillis;
+ }
+
+ /**
+ * @param directory the directory to hold the token files; created on first write with owner-only
+ * permissions
+ * @return a store rooted at the given directory
+ */
+ public static FileTokenStore at(Path directory) {
+ return new FileTokenStore(directory);
+ }
+
+ /**
+ * @return a store at {@code ${questdb.client.oidc.token.store.dir}} if that system property is set,
+ * otherwise at {@code ${user.home}/.questdb/oidc-tokens/}
+ */
+ public static FileTokenStore atDefaultLocation() {
+ String override = System.getProperty(TOKEN_STORE_DIR_PROPERTY);
+ Path dir = override != null && !override.isEmpty()
+ ? Paths.get(override)
+ : Paths.get(System.getProperty("user.home"), ".questdb", "oidc-tokens");
+ return new FileTokenStore(dir);
+ }
+
+ @Override
+ public void clear(TokenStoreKey key) {
+ if (!Files.isDirectory(directory)) {
+ return; // nothing is persisted yet; do not create the directory just to clear it
+ }
+ // Interrupt-neutral, for the reason load() and save() are, and more sharply. Those two abandon file
+ // I/O; this one is a local DELETE whose entire purpose is to erase a secret, so there is nothing to
+ // abandon on a cancellation and "we were cancelled" is not a reason to leave a plaintext refresh token
+ // behind. Routed through inLock, a merely CARRIED interrupt flag - the standard state of a cancelled
+ // or shutting-down thread, which is exactly where a sign-out runs - made inLock skip the action and
+ // return false, which this method discarded: clear() returned normally, clearCache() reported success,
+ // the file stayed on disk, and the next process start silently resumed the old identity.
+ final boolean wasInterrupted = Thread.interrupted();
+ try {
+ // delete under the cross-process lock, like the read-refresh-write, so a peer's in-flight refresh
+ // cannot resurrect the entry by atomically renaming a fresh file in just after we delete. inLock
+ // cleans up its own lock file and degrades to lock-free if it cannot acquire one. Cross-process
+ // clear is still best-effort: a peer holding a live in-memory token may legitimately re-persist
+ // later - clearing forces a fresh sign-in for THIS process regardless, since the caller resets its
+ // in-memory token state.
+ final CriticalSection delete = () -> {
+ try {
+ Files.deleteIfExists(tokenFile(key));
+ } catch (IOException e) {
+ throw new OidcAuthException(e).put("could not remove the OIDC token store file");
+ }
+ // Also remove any write temp for this identity. A crash between createTempFile and the atomic
+ // rename orphans a .tmp holding the FULL serialized entry - access, id and
+ // refresh tokens in plaintext - and until now nothing here reclaimed it: sweepStaleTempFiles
+ // runs only from save(), so a caller that clears and never signs in again left a live refresh
+ // token on disk indefinitely, contradicting this method's contract. Sweep at ANY age, unlike
+ // save()'s staleness-bounded sweep: clear() is an explicit "forget this credential", and a
+ // temp a concurrent save is mid-rename on is a benign loser - its rename fails, persistence
+ // is best-effort, and the caller is discarding the credential anyway.
+ sweepTempFiles(key.hash(), 0L);
+ return true;
+ };
+ if (!inLock(key, delete)) {
+ // inLock declined to RUN the action - a live cancellation, or it could not coordinate at all.
+ // It returns false only when the action never ran (this action always returns true), so this
+ // cannot double-delete. Run it uncoordinated rather than return with the credential still on
+ // disk: the cross-process lock only orders us against a peer's in-flight refresh, and losing
+ // that ordering costs at worst a peer re-persisting later, which this method already documents
+ // as best-effort. Leaving the secret behind is not a trade this call may make.
+ delete.run();
+ }
+ } finally {
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ @Override
+ public boolean inLock(TokenStoreKey key, CriticalSection action) {
+ // First serialize other threads of THIS JVM sharing the same identity: the cross-process file lock below
+ // degrades to lock-free after lockAcquireBudgetMillis, which is a fine cross-process fallback but must
+ // not let two threads of one process run the critical section at once (they would double-POST the same
+ // rotating refresh token and get the whole family revoked on a reuse-detecting IdP). This lock is not
+ // subject to the file lock's degrade. ReentrantLock is safe even though inLock's contract forbids
+ // nesting - a mistaken re-entry cannot self-deadlock.
+ // An interrupt CARRIED ON ENTRY is the caller's own state, not a signal aimed at any wait below:
+ // preserve it and abort before touching a lock. This has to be tested BEFORE the acquire, not after
+ // it: ReentrantLock.lockInterruptibly() begins with Thread.interrupted(), so it throws even on a FREE,
+ // UNCONTENDED lock and CLEARS the flag - a carried interrupt was therefore misread as a live
+ // cancellation by the catch below, which does not re-assert, so the caller's cancellation signal was
+ // destroyed and the critical section skipped on a lock nobody held. Aborting here also avoids
+ // acquiring a lock for a critical section we should not start, which would only delay the caller and
+ // risk stranding a lock file for its whole staleness window.
+ if (Thread.interrupted()) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ // Retain before the acquire and release in the outermost finally, so every exit - the interrupted
+ // acquire below included - gives the claim back exactly once.
+ // Namespaced by DIRECTORY as well as identity. TokenStoreKey names a CONFIGURATION and carries no
+ // directory, so keying on it alone made two stores over per-user directories - the multi-user recipe
+ // this class and the README both prescribe - queue on one lock while touching different files. That
+ // lock is held across a whole token-endpoint round trip and its acquire has no budget, so one user's
+ // stalled refresh blocked another's getToken() on the flush path, for exactly the reason the stripe
+ // table considered above was rejected.
+ final String lockIdentity = processLockIdentity(key);
+ final ProcessLock processLock = retainProcessLock(lockIdentity);
+ // lockInterruptibly, never lock(): a peer thread on this identity holds this for a whole refresh round
+ // trip, and an interrupt is the ONLY lever that reaches a caller stuck behind it. QWP's
+ // ConnectCancellation.cancel() interrupts a thread inside a credential pull precisely so close() can
+ // unstick it; an uninterruptible acquire here sleeps through that, outlives close()'s shutdown budget,
+ // and leaves the native client, the cursor engine and the slot lock to a delegated teardown.
+ try {
+ processLock.lock.lockInterruptibly();
+ } catch (InterruptedException e) {
+ // Interrupted WAITING for the process lock: a live cancellation, acted on by abandoning the
+ // refresh. RE-ASSERT the flag before returning. A bare `false` is indistinguishable from "the
+ // refresh ran and failed", which is the one thing the caller must not conclude here: signIn()
+ // reads it that way and starts the interactive device flow -- a browser launch and a poll loop
+ // that runs to the device-code lifetime on Os.sleep, which ignores interrupts -- on a thread
+ // whose owner has already asked it to stop, and getToken() reads it that way and arms the
+ // shared refresh back-off on a refresh that never happened, failing every other caller of this
+ // instance for the next five seconds. Restoring the flag is what lets signIn()'s
+ // throwIfInterrupted and getToken()'s post-refresh check tell the two apart. It is also what
+ // load() and save() already do; only this method consumed the signal.
+ //
+ // Safe to restore here: nothing below this point performs interruptible I/O -
+ // releaseProcessLock is a ConcurrentHashMap update, and no lock file was ever opened.
+ releaseProcessLock(lockIdentity); // the acquire never happened, so give the claim straight back
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ try {
+ // A LIVE interrupt that landed between the acquire above and here - the carried case is already
+ // handled before the acquire. Same answer either way: preserve it and abort before touching any
+ // lock file, rather than clear it to push the FileChannel I/O through (a set flag turns that into
+ // ClosedByInterruptException) and run the critical section anyway.
+ if (Thread.interrupted()) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ Path lock = null;
+ // the unique owner nonce stamped into the lock when we acquired it, or null if we did not (or could
+ // not) acquire one and are running lock-free; releaseLock deletes the lock only when it still carries
+ // this nonce, so we never delete a lock a peer has since stolen
+ String nonce = null;
+ // set when an interrupt arrives while we poll for the cross-process lock; see acquireLock
+ boolean cancelled = false;
+ try {
+ if (!ensureDirectory()) {
+ // As in save(). The action run under this lock is a refresh that re-reads the store, so
+ // an entry exposed before this call must not survive to be adopted by it.
+ discardUntrustedDirectoryContents();
+ }
+ lock = lockFile(key);
+ nonce = acquireLock(lock);
+ } catch (InterruptedException e) {
+ // Arrived DURING the poll, so it is a live cancellation rather than carried state. Consumed
+ // for the same reason as the process-lock wait above.
+ cancelled = true;
+ } catch (IOException | RuntimeException e) {
+ // could not prepare the lock directory or file; run without the cross-process lock. Layer-1
+ // atomic replacement still keeps every reader consistent - only a rotating-refresh-token race
+ // across processes is left unguarded for this one refresh.
+ //
+ // RuntimeException as well as IOException: this is lock BOOKKEEPING, and none of it is a
+ // reason to fail a sign-in the caller could otherwise complete. A SecurityManager denying
+ // the directory or the lock file throws SecurityException, and a filesystem that cannot
+ // carry POSIX permissions throws UnsupportedOperationException - both unchecked, both
+ // previously escaping past the caller's degrade path and aborting signIn()/getToken()
+ // outright, which is the opposite of what a best-effort store should do.
+ nonce = null;
+ }
+ try {
+ // The critical section is a fresh HTTP round trip - exactly the work a cancellation is trying
+ // to stop - so never start it once an interrupt has been observed. isInterrupted() rather than
+ // interrupted() for the late arrival: we did not catch that one, so it is not ours to clear.
+ if (cancelled || Thread.currentThread().isInterrupted()) {
+ if (cancelled) {
+ // acquireLock's poll consumed the flag; put it back for the same reason the
+ // process-lock wait above does, so the caller can tell a cancelled wait from a
+ // failed refresh. The late-arrival case needs nothing - that flag is still set.
+ // Nothing below performs interruptible I/O: cancelled implies acquireLock threw,
+ // so nonce is null and the finally below skips releaseLock.
+ Thread.currentThread().interrupt();
+ }
+ return false;
+ }
+ return action.run();
+ } finally {
+ if (nonce != null) {
+ // Release under the same shield, and re-read the flag here rather than reusing the value
+ // above: the interrupt that matters usually arrives DURING action.run() (close() breaking
+ // a stuck credential pull). Without this, releaseLock's channel read throws
+ // ClosedByInterruptException, the lock file survives its whole staleness window, and every
+ // peer degrades to an unserialized refresh meanwhile.
+ boolean wasInterruptedInSection = Thread.interrupted();
+ try {
+ releaseLock(lock, nonce);
+ } catch (RuntimeException e) {
+ // This runs in a finally, AFTER the critical section returned. A throw here would
+ // replace the caller's completed refresh with an exception - the refresh happened,
+ // the token is live, and the caller would be told the sign-in failed. releaseLock
+ // already absorbs IOException; a SecurityManager denying the delete throws
+ // SecurityException, which is unchecked and was escaping. Same operator-visible
+ // warning, same degrade: peers run unserialized until the lock goes stale.
+ // sanitized: an IO error message embeds the operator-supplied store path, which is
+ // the one untrusted string these warnings put in front of a terminal
+ LOG.warn("could not release the OIDC token store lock; peers degrade to lock-free "
+ + "refresh until it goes stale [error={}]",
+ OidcDeviceAuth.sanitizeForDisplay(e.getMessage()));
+ } finally {
+ if (wasInterruptedInSection) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+ } finally {
+ processLock.lock.unlock();
+ releaseProcessLock(lockIdentity);
+ }
+ }
+
+ @Override
+ public PersistedToken load(TokenStoreKey key) {
+ // Every file operation below goes through FileChannel, an InterruptibleChannel: a thread that
+ // merely CARRIES a set interrupt flag makes the first read throw ClosedByInterruptException and
+ // closes the channel, and the flag survives. Two callers routinely arrive here with it set - an
+ // ILP producer on a pooled or managed thread, where interrupt is the standard cancellation
+ // signal, and the sender's own I/O thread, which close() interrupts to break a stuck credential
+ // pull. Neither means "abandon the token store", so clear the flag for the duration of the file
+ // I/O and restore it on the way out: the caller's cancellation signal survives intact, while the
+ // store's reads stop being collateral damage.
+ final boolean wasInterrupted = Thread.interrupted();
+ try {
+ // Assert the directory on the READ path too, not only on the write paths. adopt() rejects an
+ // entry carrying only a refresh token, but a COMPLETE planted entry - a dummy access token, the
+ // attacker's refresh token, and an expiry already in the past - takes the normal path and the
+ // next silent refresh presents their credential. Closing that needs the container checked as
+ // well as the artefact: a store directory another local user can write is one whose contents
+ // were never ours to trust. Fail closed - a null return is the documented outcome for any
+ // unusable entry and degrades to a refresh or an interactive sign-in.
+ final boolean isDirectoryTrusted;
+ try {
+ isDirectoryTrusted = ensureDirectory();
+ } catch (IOException e) {
+ // THROW, do not return null. load()'s contract makes the two mean opposite things: null is
+ // the definitive "there is nothing here", which latches storeLoadAttempted and ends the
+ // reads for the life of the OidcDeviceAuth, while a throw reads as a transient fault and is
+ // retried under the store-load back-off. What ensureDirectory() reports here is squarely
+ // transient - Files.createDirectories failing because a home directory is not mounted yet,
+ // EIO/ESTALE on an NFS home, a momentarily read-only or full filesystem - so answering null
+ // told every later call that a store holding a perfectly good refresh token was empty. The
+ // process then re-runs the interactive device flow, and for the headless getToken()
+ // consumer this persistence exists to serve, that is a hard failure with no recovery short
+ // of a restart. The sibling arm below already throws for readBounded's IOException, and
+ // save() lets this very exception propagate; only this path disagreed.
+ warnUnprotectedStoreDirOnce("it could not be restricted to owner-only access");
+ throw new OidcAuthException(e).put("could not prepare the OIDC token store directory");
+ }
+ if (!isDirectoryTrusted) {
+ // The directory was WRITABLE by other local users until the tightening a moment ago, so
+ // anything already in it may have been planted rather than written by us. Tightening protects
+ // what we write from here on and says nothing about what was there before. Discard rather
+ // than merely skip: leaving a file behind hands it to the next load, which now sees an
+ // owner-only directory and would trust it. ALL of them, not just this key's - the verdict is
+ // spent by whoever observes it first, so the entries this call leaves are entries no later
+ // call can distrust.
+ discardUntrustedDirectoryContents();
+ return null;
+ }
+ Path file = tokenFile(key);
+ byte[] bytes;
+ try {
+ bytes = readBounded(file);
+ } catch (NoSuchFileException e) {
+ return null;
+ } catch (IOException e) {
+ throw new OidcAuthException(e).put("could not read the OIDC token store file");
+ }
+ if (bytes == null) {
+ return null;
+ }
+ return parseAndVerify(key, bytes);
+ } finally {
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ @Override
+ public void save(TokenStoreKey key, PersistedToken token) {
+ // interrupt-neutral for the same reason as load(): a carried interrupt flag would otherwise abort
+ // the write or the atomic rename half-way and leave the rotated refresh token unpersisted
+ final boolean wasInterrupted = Thread.interrupted();
+ try {
+ byte[] content = serialize(key, token);
+ try {
+ if (!ensureDirectory()) {
+ // Same verdict load() acts on, and save() is just as often the first call to touch the
+ // store - a process that signs in and persists before it ever loads. Discarding the
+ // boolean here left every entry already in the directory looking, to every later load,
+ // like it had always been protected. The fresh token below is written afterwards, into
+ // the directory ensureDirectory has by now tightened, so persistence still works.
+ discardUntrustedDirectoryContents();
+ }
+ sweepStaleTempFiles(key.hash());
+ Path target = tokenFile(key);
+ Path tmp = createTempFile(key.hash());
+ boolean moved = false;
+ try {
+ writeAndFlush(tmp, content);
+ replaceTarget(tmp, target);
+ moved = true;
+ } finally {
+ if (!moved) {
+ try {
+ Files.deleteIfExists(tmp);
+ } catch (IOException ignore) {
+ // best-effort: never let the cleanup failure replace the write/rename failure
+ // that is unwinding; sweepStaleTempFiles reclaims the orphan on a later save
+ }
+ }
+ }
+ } catch (IOException e) {
+ throw new OidcAuthException(e).put("could not persist the OIDC token to the token store");
+ }
+ } finally {
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ long getLockStaleMillis() {
+ // exposed package-private so OidcDeviceAuth.build() can verify this window dominates the worst-case time
+ // a coordinated refresh holds the lock, before a peer could otherwise judge a live lock stale and steal it
+ return lockStaleMillis;
+ }
+
+ private static void createLockFile(Path lock, String nonce) throws IOException {
+ // Exclusively create the lock (O_CREAT|O_EXCL via CREATE_NEW), then write the owner nonce into that same
+ // open channel before closing it. The file exists empty only for the tiny window between the create and
+ // the stamp; a GC/safepoint pause (or a cross-machine clock skew) CAN land in that window, so what keeps
+ // our freshly-created lock from being stolen as empty-and-stale is EMPTY_LOCK_STEAL_GRACE_MILLIS sitting
+ // well above it, not the absence of the window. FileAlreadyExists means a peer already holds it.
+ // releaseLock and stealIfStale verify this nonce before deleting. Keep the owner-only perms (and the
+ // non-POSIX fallback) to match the store's other files.
+ final byte[] bytes = nonce.getBytes(StandardCharsets.UTF_8);
+ try {
+ writeNewFile(lock, bytes, FILE_ATTRS);
+ } catch (UnsupportedOperationException e) {
+ warnNoPosixPermsOnce();
+ writeNewFile(lock, bytes);
+ }
+ }
+
+ private static void deleteCapturedLock(Path captured) {
+ // best-effort cleanup of a lock we atomically captured during a steal; a leftover .tmp is reclaimed by
+ // sweepStaleTempFiles on a later save
+ try {
+ Files.deleteIfExists(captured);
+ } catch (IOException ignore) {
+ // reclaimed by sweepStaleTempFiles later
+ }
+ }
+
+ /**
+ * Whether {@code name} starts with the 64-character lowercase-hex identity fingerprint every file this
+ * store writes is named after.
+ *
+ * This is the test for "we could have written this", used where a sweep has no single
+ * {@link TokenStoreKey} to scope itself by and must therefore recognise the store's files by shape
+ * rather than by an exact name. It is deliberately a prefix test: the entry is
+ * {@code .json} but a write temp is {@code .tmp}, so only the leading fingerprint
+ * is common to both.
+ *
+ * Case matters. {@code TokenStoreKey} renders the digest through {@link #HEX}, which is lowercase, so
+ * an uppercase-hex name is not one of ours and is left alone.
+ */
+ private static boolean hasStoreHashPrefix(String name) {
+ if (name.length() < HASH_NAME_LENGTH) {
+ return false;
+ }
+ for (int i = 0; i < HASH_NAME_LENGTH; i++) {
+ final char c = name.charAt(i);
+ if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static String newLockNonce() {
+ // A per-acquisition owner stamp: the acquire time is a human-readable debugging aid, and the random
+ // UUID guarantees two acquisitions never share a stamp even within one pid and one millisecond, so
+ // releaseLock's ownership check is exact rather than probabilistic.
+ //
+ // NO pid@host, deliberately. The obvious way to get one on Java 8 is
+ // ManagementFactory.getRuntimeMXBean().getName(), and that RESOLVES THE LOCAL HOSTNAME:
+ // VMManagementImpl.getVmId() calls InetAddress.getLocalHost(), a full resolver round trip. Measured
+ // at 3162ms on a macOS dev box whose mDNS cache was cold - inside an acquire whose entire budget was
+ // 200ms, on the producer thread, holding this store's in-process lock and the caller's
+ // OidcDeviceAuth lock. That is the documented bound (inLock degrades to a lock-free refresh after
+ // lockAcquireBudgetMillis) broken by a debugging aid, and it is a once-per-JVM surprise: the value is
+ // cached inside the MXBean afterwards, so the very first credential refresh in a process paid for it
+ // and nothing later did. Java has no cheap hostname - unlike Python's socket.gethostname(), which is
+ // gethostname(2) and does not resolve - so the field goes rather than the bound. Nothing reads it:
+ // releaseLock and stealIfStale compare the stamp byte-wise against their own, and the cross-language
+ // contract has each implementation check only its own stamp (design/oidc-token-persistence.md).
+ //
+ // Not even Compat.currentPid(), which SlotLock uses for exactly this kind of diagnostic: its Java 9+
+ // variant is a free ProcessHandle.current().pid(), but its Java 8 variant IS the getName() call above,
+ // parsed for the part before the '@'. The bound has to hold on every runtime this artifact supports,
+ // not only on modern ones, so the stamp carries no process identity at all.
+ return System.currentTimeMillis() + " " + UUID.randomUUID();
+ }
+
+
+ private static boolean nullableEquals(String keyValue, StringSink fileValue) {
+ boolean fileHasValue = fileValue.length() > 0;
+ if (keyValue == null) {
+ return !fileHasValue;
+ }
+ return fileHasValue && Chars.equals(keyValue, fileValue);
+ }
+
+ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) {
+ if (bytes.length == 0) {
+ return null;
+ }
+ TokenFileParser parser = new TokenFileParser();
+ try (DirectUtf8Sink mem = new DirectUtf8Sink(bytes.length);
+ JsonLexer lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES)) {
+ // bulk-copy the file bytes into native memory in one go rather than byte by byte
+ mem.put(bytes, 0, bytes.length);
+ long lo = mem.ptr();
+ lexer.parse(lo, lo + mem.size(), parser);
+ lexer.parseLast(); // reject a truncated document
+ } catch (JsonException e) {
+ // corrupt or truncated file: treat as no usable entry, fall back to refresh / interactive
+ return null;
+ }
+ // schema and fingerprint must match the live identity; a mismatch is a hash collision or a file
+ // copied from a different identity, so ignore it rather than serve the wrong identity's token. A
+ // malformed shape (an array anywhere - the schema is a single flat object) is likewise rejected.
+ if (parser.malformed || parser.version != SCHEMA_VERSION) {
+ return null;
+ }
+ if (!Chars.equals(key.getClientId(), parser.clientId)
+ || !Chars.equals(key.getTokenEndpoint(), parser.tokenEndpoint)
+ || !Chars.equals(key.getDeviceAuthorizationEndpoint(), parser.deviceAuthorizationEndpoint)
+ || !Chars.equals(key.getScope(), parser.scope)
+ || !nullableEquals(key.getAudience(), parser.audience)
+ || key.isGroupsInToken() != parser.groupsInToken) {
+ return null;
+ }
+ String accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null;
+ String idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null;
+ String refreshToken = parser.refreshToken.length() > 0 ? parser.refreshToken.toString() : null;
+ return new PersistedToken(accessToken, idToken, refreshToken, parser.expiresAtMillis, parser.tokenTtlMillis);
+ }
+
+ private static long parseLongOrZero(CharSequence value) {
+ // The frozen on-disk contract stores these as JSON numbers: an optional '-' followed by bare digits.
+ // Numbers.parseLong is more permissive than that - it accepts '_' thousands separators and an 'L'/'l'
+ // suffix - so "5L" and "1_000" would parse here and fail in every other language client reading the
+ // same file, which is exactly the kind of silent divergence a frozen cross-language format exists to
+ // prevent. Screen the value first so this client accepts only what the format actually allows; an
+ // out-of-contract value falls back to 0 like any other unusable field.
+ final int n = value.length();
+ int i = n > 0 && value.charAt(0) == '-' ? 1 : 0;
+ if (i == n) {
+ return 0; // empty, or a bare "-"
+ }
+ for (; i < n; i++) {
+ char c = value.charAt(i);
+ if (c < '0' || c > '9') {
+ return 0;
+ }
+ }
+ try {
+ return Numbers.parseLong(value);
+ } catch (NumericException e) {
+ return 0;
+ }
+ }
+
+ private static void putBooleanMember(StringSink sink, String name, boolean value) {
+ sink.put(',');
+ putName(sink, name);
+ sink.put(Boolean.toString(value));
+ }
+
+ private static void putLongMember(StringSink sink, String name, long value) {
+ sink.put(',');
+ putName(sink, name);
+ // write the digits unconditionally. sink.put(long) routes through Numbers.append(..., checkNaN=true),
+ // which renders Long.MIN_VALUE as the literal JSON null - a bare null for a present, non-nullable
+ // integer field would break the frozen cross-language contract (serialize() OMITS absent fields rather
+ // than writing null, so a null here is indistinguishable from absent) and round-trips back to 0 via
+ // parseLongOrZero. checkNaN=false emits the full number, so every long value round-trips verbatim.
+ Numbers.append(sink, value, false);
+ }
+
+ private static void putName(StringSink sink, String name) {
+ sink.put('"').put(name).put('"').put(':');
+ }
+
+ private static void putNullableStringMember(StringSink sink, String name, String value) {
+ // omit the member entirely when the value is null, rather than write a JSON null - see serialize()
+ if (value != null) {
+ putStringMember(sink, name, value);
+ }
+ }
+
+ private static void putString(StringSink sink, CharSequence value) {
+ // a refresh token is an opaque IdP string, so escape the JSON string properly; the JsonLexer
+ // decodes these on read. Non-ASCII passes through and is encoded as UTF-8 by getBytes below.
+ sink.put('"');
+ for (int i = 0, n = value.length(); i < n; i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"':
+ sink.put("\\\"");
+ break;
+ case '\\':
+ sink.put("\\\\");
+ break;
+ case '\b':
+ sink.put("\\b");
+ break;
+ case '\f':
+ sink.put("\\f");
+ break;
+ case '\n':
+ sink.put("\\n");
+ break;
+ case '\r':
+ sink.put("\\r");
+ break;
+ case '\t':
+ sink.put("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ sink.put("\\u00").put(HEX[(c >> 4) & 0x0f]).put(HEX[c & 0x0f]);
+ } else {
+ sink.put(c);
+ }
+ }
+ }
+ sink.put('"');
+ }
+
+ private static void putStringMember(StringSink sink, String name, CharSequence value) {
+ sink.put(',');
+ putName(sink, name);
+ putString(sink, value);
+ }
+
+ // Drops this caller's claim on the identity's lock, retiring the entry when it was the last one, so the
+ // map never outgrows the identities actually in flight. Pairs with retainProcessLock in a finally.
+ private static void releaseProcessLock(String identity) {
+ PROCESS_LOCKS.computeIfPresent(identity, (k, held) -> --held.users == 0 ? null : held);
+ }
+
+ // Claims the lock for this identity, creating the entry if this caller is the first to arrive. Registers
+ // the claim BEFORE the acquire, so an entry cannot be retired out from under a thread that is queued on
+ // it - which is what makes the retirement in releaseProcessLock safe.
+ private static ProcessLock retainProcessLock(String identity) {
+ return PROCESS_LOCKS.compute(identity, (k, existing) -> {
+ final ProcessLock held = existing != null ? existing : new ProcessLock();
+ held.users++;
+ return held;
+ });
+ }
+
+ private static byte[] readBounded(Path file) throws IOException {
+ // read with a hard cap instead of Files.readAllBytes after a separate Files.size: the file is
+ // attacker-writable, so a size-check-then-read races a concurrent grow - a file enlarged past the cap
+ // between the two would make readAllBytes allocate gigabytes and throw OutOfMemoryError (an Error, which
+ // the best-effort RuntimeException guard in OidcDeviceAuth.maybeLoadFromStore would not catch, so a bad
+ // file would abort sign-in instead of degrading). Cap the buffer at the reported size (already bounded
+ // by MAX_FILE_BYTES) plus one byte, so a file that grew past its reported size is rejected, not allocated.
+ try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
+ long size = channel.size();
+ if (size <= 0 || size > MAX_FILE_BYTES) {
+ // an empty or implausibly large file is not a usable entry; ignore it rather than read it in
+ return null;
+ }
+ ByteBuffer buffer = ByteBuffer.allocate((int) size + 1);
+ while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
+ // read until EOF or the (size + 1)-byte buffer fills
+ }
+ int read = buffer.position();
+ if (read == 0 || read > size) {
+ // empty, or grew past its reported size between the stat and the read: treat as corrupt/hostile
+ return null;
+ }
+ byte[] bytes = new byte[read];
+ buffer.flip();
+ buffer.get(bytes);
+ return bytes;
+ }
+ }
+
+ private static byte[] readLockHolder(Path lock) throws IOException {
+ // read the lock's owner stamp with a hard cap rather than Files.readAllBytes: the .lock file
+ // sits in the same attacker-writable directory as the token file, so an inflated lock would otherwise
+ // make readAllBytes allocate without bound and throw OutOfMemoryError - an Error the best-effort
+ // RuntimeException guards on the getToken()/signIn() refresh path would not catch, aborting the
+ // sign-in (the same reason readBounded caps the token file). A real owner stamp is a few hundred
+ // bytes; anything past the cap is corrupt or hostile, so report it as unreadable (null) rather than
+ // read it into memory. Returns the exact bytes present, or null for an empty/oversized lock.
+ try (FileChannel channel = FileChannel.open(lock, StandardOpenOption.READ)) {
+ long size = channel.size();
+ if (size <= 0 || size > MAX_LOCK_FILE_BYTES) {
+ return null;
+ }
+ ByteBuffer buffer = ByteBuffer.allocate((int) size);
+ while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
+ // read until EOF or the buffer fills
+ }
+ int read = buffer.position();
+ if (read == 0) {
+ return null;
+ }
+ byte[] bytes = new byte[read];
+ buffer.flip();
+ buffer.get(bytes);
+ return bytes;
+ }
+ }
+
+ private static void releaseLock(Path lock, String nonce) {
+ // release our own lock only: re-read it (bounded - see readLockHolder) and delete it solely when it
+ // still carries our nonce. A hold that outran lockStaleMillis may have been judged stale and stolen
+ // (captured and recreated) by a peer; deleting by bare path would then remove the peer's live lock and
+ // admit a third acquirer alongside it, defeating the mutual exclusion this lock exists to provide. A
+ // microscopic window remains if a steal lands between the read and the delete, but that is bounded to
+ // one syscall gap rather than the whole hold, so a misconfigured staleness window degrades to at most
+ // the documented double-refresh rather than corrupting a peer's lock state.
+ try {
+ byte[] content = readLockHolder(lock);
+ if (content != null && nonce.equals(new String(content, StandardCharsets.UTF_8))) {
+ Files.deleteIfExists(lock);
+ }
+ // otherwise a peer now owns this lock file, or it is unreadable/oversized; leave it for that owner
+ // (or the staleness steal) to reclaim
+ } catch (NoSuchFileException e) {
+ // already gone (stolen and not yet recreated, or removed elsewhere); nothing to release
+ } catch (IOException e) {
+ // Best-effort release, but no longer silent. A lock we could not delete blocks every peer's
+ // coordinated refresh until it goes stale (lockStaleMillis - 10 minutes by default), and each
+ // peer degrades to an unserialized refresh meanwhile: the rotating-refresh-token race this lock
+ // exists to prevent. That is worth a line an operator can find, rather than surfacing later as
+ // unexplained repeated sign-ins.
+ // sanitized: see the sibling warning in inLock - the message embeds the store path
+ LOG.warn("could not release the OIDC token store lock; peers degrade to lock-free refresh until "
+ + "it goes stale [error={}]", OidcDeviceAuth.sanitizeForDisplay(e.getMessage()));
+ }
+ }
+
+ private static void replaceTarget(Path tmp, Path target) throws IOException {
+ // atomically rename tmp over target. On Windows a concurrent reader in any process holding target open
+ // can make the rename fail transiently with AccessDeniedException (a sharing violation); retry a few
+ // times on a short backoff before giving up, so a routine read/write overlap does not needlessly degrade
+ // persistence (best-effort - the in-memory token is still valid). POSIX rename over an open file never
+ // hits this. AtomicMoveNotSupported (a rare filesystem) falls back to a plain replace, which still beats
+ // leaving a partial write.
+ AccessDeniedException lastDenied = null;
+ for (int attempt = 0; attempt < REPLACE_MAX_ATTEMPTS; attempt++) {
+ if (attempt > 0) {
+ Os.sleep(REPLACE_RETRY_SLEEP_MILLIS);
+ }
+ try {
+ Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ return;
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
+ return;
+ } catch (AccessDeniedException e) {
+ lastDenied = e;
+ }
+ }
+ throw lastDenied;
+ }
+
+ /**
+ * Re-asserts owner-only permissions on the store directory. The at-rest protection of the plaintext token
+ * files is exactly these permissions, so a pre-existing directory another tool or a permissive umask left
+ * loose is tightened rather than trusted as it stands. ensureDirectory runs this on every save and every
+ * inLock, so it chmods only on detected drift - the common case costs one stat and no write syscall.
+ *
+ * NOT best-effort on the failure that matters. An {@code IOException} here means the directory is not ours
+ * to chmod, which is precisely the state in which the documented {@code 0700} protection does not hold and
+ * another local user can create, replace or delete entries in it. Swallowing it left every caller believing
+ * the protection applied. Callers degrade on the throw, each in the way that suits it: {@code save} refuses
+ * to write a plaintext refresh token into a directory it cannot protect, {@code inLock} runs lock-free.
+ * On a non-POSIX filesystem (Windows) the check is unavailable rather than failed, so it falls back to the
+ * inherited ACL as before (owner-only hardening there, via AclFileAttributeView, is a separate follow-up).
+ *
+ * @param directory the store directory, which must already exist
+ * @return {@code true} when the directory's content may be trusted, {@code false} when it was writable
+ * by group or other, so another local user could have planted an entry before this call
+ * tightened it
+ * @throws IOException if the directory exists but its permissions cannot be read or set
+ */
+ private static boolean restrictToOwner(Path directory) throws IOException {
+ try {
+ final Set perms = Files.getPosixFilePermissions(directory);
+ // Writable by group or other is the state that decides TRUST, and it is narrower than "not
+ // owner-only": only write permission on a directory lets another local user create or replace an
+ // entry in it, which is what load() would then adopt. The 0755 a default umask produces exposes
+ // no token - the files themselves are 0600 - and everything in it was still put there by us, so
+ // it is tightened for defence in depth but its content stays trusted.
+ final boolean wasOtherWritable = perms.contains(PosixFilePermission.GROUP_WRITE)
+ || perms.contains(PosixFilePermission.OTHERS_WRITE);
+ if (!DIR_PERMS.equals(perms)) {
+ // Tightening is load-bearing - it IS the at-rest protection of the plaintext token files, so
+ // it stays unconditional - but it changes a directory the operator chose and may share with
+ // something else, so it must not be silent. Once per JVM, and never naming the path.
+ if (warnedTightenedStoreDir.compareAndSet(false, true)) {
+ LOG.warn("the OIDC token store directory was not owner-only and has been tightened to "
+ + "0700; it holds plaintext refresh tokens, so it must not be shared with "
+ + "anything else. Point questdb.client.oidc.token.store.dir at a directory of "
+ + "its own if another tool needs access to that path.");
+ }
+ Files.setPosixFilePermissions(directory, DIR_PERMS);
+ }
+ return !wasOtherWritable;
+ } catch (UnsupportedOperationException e) {
+ // non-POSIX FS (e.g. Windows): cannot enforce owner-only perms; keep the inherited ACL
+ warnNoPosixPermsOnce();
+ return true;
+ }
+ }
+
+ private static byte[] serialize(TokenStoreKey key, PersistedToken token) {
+ // a null value (an absent audience, or a token kind the grant did not return) is omitted rather than
+ // written as JSON null: the JsonLexer reports a bare null and a quoted "null" identically, so omitting
+ // absent fields is the only encoding under which a present value - a token equal to "null" included -
+ // round-trips back verbatim. "v" is the first member; every later member prepends its own comma.
+ StringSink sink = new StringSink();
+ sink.put('{');
+ putName(sink, "v");
+ sink.put(SCHEMA_VERSION);
+ putStringMember(sink, "client_id", key.getClientId());
+ putStringMember(sink, "token_endpoint", key.getTokenEndpoint());
+ putStringMember(sink, "device_authorization_endpoint", key.getDeviceAuthorizationEndpoint());
+ putStringMember(sink, "scope", key.getScope());
+ putNullableStringMember(sink, "audience", key.getAudience());
+ putBooleanMember(sink, "groups_in_token", key.isGroupsInToken());
+ putNullableStringMember(sink, "access_token", token.getAccessToken());
+ putNullableStringMember(sink, "id_token", token.getIdToken());
+ putNullableStringMember(sink, "refresh_token", token.getRefreshToken());
+ putLongMember(sink, "expires_at_millis", token.getExpiresAtMillis());
+ putLongMember(sink, "token_ttl_millis", token.getTokenTtlMillis());
+ sink.put('}');
+ return sink.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static void warnNoPosixPermsOnce() {
+ // best-effort, once per JVM: the token store could not enforce 0600/0700, so the persisted refresh
+ // token is protected only by the directory's inherited ACL. ASCII-only, and never includes a path or
+ // token byte (a path could itself carry terminal-spoofing characters)
+ if (!warnedNoPosixPerms.compareAndSet(false, true)) {
+ return;
+ }
+ LOG.warn("the OIDC token store could not enforce owner-only (0600/0700) permissions on this "
+ + "filesystem; the persisted refresh token is protected only by the directory's default ACL. "
+ + "Back the store with an OS keychain for at-rest encryption.");
+ }
+
+ private static void warnUnprotectedStoreDirOnce(String reason) {
+ // once per JVM, like warnNoPosixPermsOnce: the condition is a property of the directory, which every
+ // identity in this process shares, and load() sits on the flush path via OidcDeviceAuth.getToken().
+ // ASCII-only and never the path itself - an operator-supplied path can carry terminal-spoofing
+ // characters, which is why warnNoPosixPermsOnce omits it too.
+ if (!warnedUnprotectedStoreDir.compareAndSet(false, true)) {
+ return;
+ }
+ LOG.warn("the OIDC token store directory is not owner-only, so a persisted token there cannot be "
+ + "trusted: {}. Point questdb.client.oidc.token.store.dir at a directory only this user "
+ + "can write, or supply a TokenStore backed by an OS keychain.", reason);
+ }
+
+ private static void writeAndFlush(Path file, byte[] content) throws IOException {
+ // write the payload and force it to disk before the rename, so a crash between the write and the
+ // atomic rename cannot leave the target pointing at unflushed (zero/partial) bytes - the temp file
+ // is the durability point of the write-temp / flush / atomic-rename protocol
+ try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ ByteBuffer buffer = ByteBuffer.wrap(content);
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ channel.force(true);
+ }
+ }
+
+ private static void writeNewFile(Path file, byte[] content, FileAttribute>... attrs) throws IOException {
+ // exclusive-create (CREATE_NEW = O_CREAT|O_EXCL) with the given perms and write the content in one
+ // open; FileAlreadyExistsException is raised when the file already exists
+ try (FileChannel channel = FileChannel.open(file, EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), attrs)) {
+ ByteBuffer buffer = ByteBuffer.wrap(content);
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ }
+ }
+
+ private String acquireLock(Path lock) throws InterruptedException {
+ // returns the unique owner nonce stamped into the lock on success, or null if it could not be acquired
+ // within the budget. releaseLock uses the nonce to verify ownership before deleting, so a hold that
+ // outran lockStaleMillis (and was stolen by a peer) never deletes the peer's lock on release
+ final String nonce = newLockNonce();
+ // nanoTime, not currentTimeMillis: this is an elapsed budget, and the wall clock is adjustable. An
+ // NTP step or an operator setting the date back stretches a millis-based deadline by the size of the
+ // jump, so a caller that documents a bounded degrade - inLock() promises to fall back to a lock-free
+ // refresh after lockAcquireBudgetMillis - would sit here for however long the clock moved instead.
+ // nanoTime is monotonic and immune to that. Compare by DIFFERENCE rather than by ordering, so the
+ // arithmetic stays correct across nanoTime's wraparound.
+ final long deadlineNanos = System.nanoTime() + lockAcquireBudgetMillis * 1_000_000L;
+ while (true) {
+ try {
+ // exclusive-create then stamp on the same open channel: the empty-file window between the two is
+ // tiny and covered by EMPTY_LOCK_STEAL_GRACE_MILLIS, so a GC/safepoint pause mid-acquisition
+ // cannot get our freshly-created lock stolen as empty-and-stale
+ createLockFile(lock, nonce);
+ return nonce;
+ } catch (FileAlreadyExistsException e) {
+ // the lock exists; if a crashed holder abandoned it, steal it - atomically and stamp-verified,
+ // so a stealer never removes a peer's freshly-created live lock (see stealIfStale). Then fall
+ // through to the bounded wait below rather than retry immediately: a steal contest between
+ // several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin.
+ stealIfStale(lock);
+ if (System.nanoTime() - deadlineNanos >= 0) {
+ return null; // give up and run without the lock rather than stall a sign-in
+ }
+ // Thread.sleep, not Os.sleep: Os.sleep catches InterruptedException and keeps sleeping to its
+ // deadline WITHOUT re-asserting the flag, so a cancellation aimed at this poll was swallowed
+ // outright and the whole budget elapsed regardless. Propagate it and let inLock abandon the
+ // refresh - the budget can be tens of seconds, far past a QWP close()'s shutdown window.
+ Thread.sleep(LOCK_POLL_SLICE_MILLIS);
+ } catch (IOException e) {
+ // Do NOT delete the lock here. That used to be justified by "the exclusive create succeeded
+ // and only the nonce write failed, so the file is ours" - true for one of the failures this
+ // arm catches, but not the others. From the second loop iteration onward a PEER's live lock
+ // occupies the path, and plenty of "cannot create" failures are not
+ // FileAlreadyExistsException: fd exhaustion (EMFILE/ENFILE), EACCES, EROFS, ENOSPC and a
+ // Windows sharing violation all arrive as a plain IOException. deleteIfExists cannot tell
+ // the two cases apart, and removing a peer's live lock admits a second holder - the
+ // double-POST of one rotating refresh token this lock exists to prevent, which a
+ // reuse-detecting identity provider answers by revoking the whole token family.
+ //
+ // A lock we genuinely did leave half-created is EMPTY, and stealIfStale already reclaims an
+ // empty lock on the short EMPTY_LOCK_STEAL_GRACE_MILLIS grace, so leaving it behind costs at
+ // most that grace. Degrade to a lock-free refresh instead.
+ // sanitized: see the sibling warning in inLock - the message embeds the store path
+ LOG.warn("could not acquire the OIDC token store lock; running this refresh without "
+ + "cross-process coordination [error={}]",
+ OidcDeviceAuth.sanitizeForDisplay(e.getMessage()));
+ return null;
+ }
+ }
+ }
+
+ private Path createTempFile(String prefix) throws IOException {
+ try {
+ return Files.createTempFile(directory, prefix, ".tmp", FILE_ATTRS);
+ } catch (UnsupportedOperationException e) {
+ // non-POSIX filesystem (e.g. Windows): rely on the owner-only directory ACL instead
+ warnNoPosixPermsOnce();
+ return Files.createTempFile(directory, prefix, ".tmp");
+ }
+ }
+
+ /**
+ * Discards EVERY entry in the store directory, and warns once, after {@link #restrictToOwner(Path)} has
+ * reported it was writable by other local users.
+ *
+ * Discarding only the caller's own {@code .json} is not enough, because the verdict is destroyed by
+ * the act of reporting it: restrictToOwner chmods the directory to 0700 as it returns, so whichever
+ * caller touches the store first consumes the one observation. Every later load - in this process or the
+ * next - then sees an owner-only directory and adopts whatever entry is sitting there, including one
+ * planted while the directory stood open. One store directory holds one file per configuration and is
+ * documented as belonging to the store alone, so once it has been writable by other local users nothing
+ * in it can be told apart from a plant: all of it goes, and each identity re-signs in.
+ *
+ * Best-effort by design. This runs on the flush path through {@code OidcDeviceAuth.getToken()}, so a
+ * delete that fails must degrade to a refresh or an interactive sign-in rather than throw - and the
+ * caller that triggered it fails closed either way, whether or not the file goes.
+ */
+ private void discardUntrustedDirectoryContents() {
+ warnUnprotectedStoreDirOnce("it was writable by other local users; every entry found in it was "
+ + "discarded rather than trusted, and a fresh sign-in is required");
+ try (DirectoryStream stream = Files.newDirectoryStream(directory)) {
+ for (Path entry : stream) {
+ final String name = entry.getFileName().toString();
+ // Only files THIS STORE could have written. Every one of them is named after a 64-hex
+ // identity fingerprint - tokenFile() builds ".json" and writeTemp() asks
+ // createTempFile() for ".tmp" - so a name without that prefix belongs to
+ // whatever else shares the directory. Without this test the filter below reads as "any
+ // .json file", and the operator who pointed questdb.client.oidc.token.store.dir at a
+ // directory holding their own config loses it on the first load(). The directory being
+ // group-writable is what brought us here; it is not a licence to delete files we did not
+ // write. sweepTempFiles() already scopes itself this way, by hash prefix.
+ if (!hasStoreHashPrefix(name)) {
+ continue;
+ }
+ // The entry is exactly ".json"; anything longer that merely starts with a hash is
+ // not ours either. A write temp carries a random infix, so only its suffix is fixed.
+ final boolean isEntry = name.length() == HASH_NAME_LENGTH + 5 && name.endsWith(".json");
+ // A steal-captured lock (.lock..tmp) is a cross-process handover in flight, not
+ // an orphaned write temp - sweepTempFiles skips it for the same reason. The .lock files
+ // themselves stay too: they carry no token, and acquireLock already treats a hostile or
+ // stale one as stealable.
+ final boolean isWriteTemp = name.endsWith(".tmp") && !name.contains(".lock.");
+ if (!isEntry && !isWriteTemp) {
+ continue;
+ }
+ try {
+ Files.deleteIfExists(entry);
+ } catch (IOException ignore) {
+ // best-effort; skip this entry, the caller still fails closed
+ }
+ }
+ } catch (IOException ignore) {
+ // best-effort; an unreadable directory must not turn a fail-closed load into a thrown one
+ }
+ }
+
+ /**
+ * Creates the store directory owner-only when absent, and asserts it is owner-only either way.
+ *
+ * @return {@code true} when the directory's content may be trusted - see {@link #restrictToOwner(Path)}
+ * @throws IOException if the directory cannot be created, or exists and cannot be made owner-only
+ */
+ private boolean ensureDirectory() throws IOException {
+ if (!Files.isDirectory(directory)) {
+ try {
+ Files.createDirectories(directory, DIR_ATTRS);
+ } catch (UnsupportedOperationException e) {
+ warnNoPosixPermsOnce();
+ Files.createDirectories(directory);
+ }
+ }
+ // Verify UNCONDITIONALLY, including immediately after our own create, rather than only on the
+ // pre-existing branch. createDirectories is a no-op when the directory already exists, and it applies
+ // DIR_ATTRS only to directories it actually creates - so a peer that won the race between the
+ // isDirectory check above and the call keeps whatever permissions IT chose, and "we called
+ // createDirectories" is not evidence the directory is ours. That window is exactly the hostile
+ // local pre-create this method exists to defeat. Re-asserting here also covers ordinary drift on a
+ // pre-existing directory (another tool, a permissive umask), which is what the old branch handled.
+ return restrictToOwner(directory);
+ }
+
+ private boolean isOlderThan(Path lock, long thresholdMillis) {
+ try {
+ FileTime modified = Files.getLastModifiedTime(lock);
+ return System.currentTimeMillis() - modified.toMillis() > thresholdMillis;
+ } catch (IOException e) {
+ return false; // cannot determine the age; do not steal
+ }
+ }
+
+ private Path lockFile(TokenStoreKey key) {
+ return directory.resolve(key.hash() + ".lock");
+ }
+
+ /**
+ * The key this store's entry for {@code key} takes in {@link #PROCESS_LOCKS}: the normalized store
+ * directory and the identity fingerprint, NUL-separated.
+ *
+ * Both halves are load-bearing. The fingerprint alone over-serializes, because it names a
+ * configuration and says nothing about where the entry lives - so two stores following the
+ * documented per-user-directory recipe would queue on one lock while touching different files, and that
+ * lock is held across a whole token-endpoint round trip with no acquire budget. The directory alone
+ * under-serializes, letting two identities in one directory run their read-refresh-write concurrently.
+ *
+ * NUL is the separator for the reason {@code TokenStoreKey} uses it: a path can contain almost anything
+ * else, and two different (directory, identity) pairs must never render to one string.
+ */
+ private String processLockIdentity(TokenStoreKey key) {
+ return lockNamespace + '\0' + key.hash();
+ }
+
+ private void stealIfStale(Path lock) {
+ // Steal a lock abandoned by a crashed holder, but never remove a peer's freshly-created LIVE lock. A
+ // bare deleteIfExists(lock) removes whatever sits at the path at that instant - including a fresh lock
+ // a peer created in the gap since we judged the old one stale - and would admit two holders at once.
+ // Instead: read the current owner stamp, confirm the lock is stale, then capture it atomically into a
+ // private name (rename is atomic, so among racing stealers exactly one captures it; the losers get
+ // NoSuchFileException and fall back to the wait), then verify what we captured carries the same stamp
+ // we judged stale. If a peer had already replaced it with a live lock we grabbed that instead, so we
+ // put it back rather than steal it. This mirrors releaseLock's own-stamp check and shrinks the
+ // residual race from the whole age-check->delete gap to the gap between the two renames.
+ final byte[] before;
+ try {
+ before = readLockHolder(lock);
+ } catch (IOException e) {
+ return; // gone or unreadable; nothing to steal here - the create attempt or a peer settles it
+ }
+ // read the stamp first, then check age: if a peer replaces the lock with a fresh one in between, the
+ // age check reads the fresh mtime and returns false, so we never proceed against a live lock.
+ if (before != null) {
+ // a stamped lock is a (claimed) live holder: steal only once it outlives the full staleness window
+ if (!isOlderThan(lock, lockStaleMillis)) {
+ return;
+ }
+ } else if (!isOlderThan(lock, EMPTY_LOCK_STEAL_GRACE_MILLIS)) {
+ // an empty/unreadable lock is almost never a validly-held lock: acquireLock creates the lock and
+ // stamps the owner nonce onto the same open channel (createLockFile via CREATE_NEW), so a live lock
+ // carries its stamp within the tiny create->stamp window. An empty lock therefore means either a
+ // crash mid-write (the exclusive create succeeded but the nonce write did not) or a peer momentarily
+ // caught in that narrow window - a GC/safepoint pause CAN land there, which is exactly why the grace
+ // exists. Steal it on the short empty-lock grace rather than the full staleness window, so a crash
+ // orphan stops wedging peers for the whole window; the capture-verify below still confirms the lock
+ // is unchanged before completing the steal. (A cross-machine clock skew wider than the grace could
+ // still pre-empt such a partial lock, but that never forges or tears a credential - Layer-1's
+ // atomic rename holds - it degrades to at most a concurrent refresh, the best-effort residual
+ // inLock already accepts.)
+ //
+ // The grace is used verbatim, never clamped down to a smaller lockStaleMillis. It is the one thing
+ // standing between a peer caught mid-stamp and having its live lock stolen, so the frozen
+ // cross-language contract (design/oidc-token-persistence.md) states that a client MUST NOT shorten
+ // it - and a store built with a staleness window under 5s would otherwise do exactly that,
+ // silently. A short window is a legitimate way to say "steal an abandoned STAMPED lock quickly";
+ // it is not a statement about the create-to-stamp gap, which is the same few microseconds however
+ // the store is configured.
+ return;
+ }
+ final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp");
+ try {
+ Files.move(lock, captured, StandardCopyOption.ATOMIC_MOVE);
+ } catch (IOException e) {
+ // NoSuchFile: a peer already stole/removed it; AtomicMoveNotSupported or other IO: degrade and
+ // leave the lock for the staleness path. Either way we have not removed a peer's live lock.
+ return;
+ }
+ byte[] after = null;
+ boolean afterReadOk = false;
+ try {
+ after = readLockHolder(captured);
+ afterReadOk = true;
+ } catch (IOException ignore) {
+ // captured but cannot re-read; treated as a non-match below, so we restore rather than steal
+ }
+ // Confirm we captured the same stamp we judged stale (or the same empty/oversized junk), not a live
+ // lock a peer recreated in the gap. afterReadOk is load-bearing and separate from "after == null":
+ // readLockHolder returns null for a legitimately empty or oversized lock but THROWS on an IO error,
+ // and folding those together let a failed re-read of an empty lock read as confirmedStale - so the
+ // steal completed on the strength of an IO error rather than on evidence the lock was unchanged,
+ // the exact opposite of what the catch above says it does.
+ final boolean confirmedStale = afterReadOk
+ && (before == null ? after == null : Arrays.equals(before, after));
+ if (confirmedStale) {
+ // genuinely the abandoned lock: drop it, so the next createLockFile can claim a fresh one
+ deleteCapturedLock(captured);
+ return;
+ }
+ // We captured a live lock a peer recreated in the gap (or could not re-read what we captured): put it
+ // back rather than steal it.
+ //
+ // Restore by hard-LINKING our capture back to the lock path, not by renaming it. Files.move without
+ // REPLACE_EXISTING looks atomic but is not: it stats the target, then renames, and rename(2) silently
+ // replaces. A third party that claims the freed path between those two steps therefore had its live
+ // lock destroyed by the very call whose comment promised to leave it intact. link(2) has no such gap -
+ // it fails outright when the target exists - and it preserves the peer's exact bytes, which matters
+ // because releaseLock verifies the stamp before deleting.
+ //
+ // A residual remains and is not closeable with a lock file: if a third party did claim the path, we
+ // drop our copy, so the recreating peer's lock file is gone while that peer still believes it holds
+ // the lock, and for that one refresh two holders can run concurrently. A filesystem offers no atomic
+ // "delete or rename only if the content is still X", so the capture-verify narrows the window to this
+ // multi-actor race - our steal, a peer recreating, AND a third party claiming the freed path, all
+ // overlapping - without eliminating it. Best-effort by design: it degrades to one extra refresh, a
+ // re-prompt on a rotating-refresh-token identity provider, never a torn or forged credential
+ // (Layer 1's atomic rename still holds).
+ try {
+ Files.createLink(lock, captured);
+ deleteCapturedLock(captured);
+ } catch (FileAlreadyExistsException e) {
+ // a third party owns the path now; leave their lock untouched and drop our copy
+ deleteCapturedLock(captured);
+ } catch (IOException | UnsupportedOperationException e) {
+ // the filesystem does not support hard links, or the link failed for another reason. Fall back to
+ // the plain move: it preserves the bytes but reopens the stat-then-rename window described above,
+ // which is still better than abandoning the peer's lock outright.
+ try {
+ Files.move(captured, lock);
+ } catch (IOException moveFailure) {
+ deleteCapturedLock(captured);
+ }
+ }
+ }
+
+ private void sweepStaleTempFiles(String hashPrefix) {
+ // a crash between createTempFile and the atomic rename orphans a *.tmp holding a
+ // valid-at-the-time refresh token; unlike the lock file nothing ever steals it, so it would accumulate
+ // across crashes. Best-effort sweep on save: delete only temps older than the lock-staleness window, so
+ // a temp a concurrent writer is actively using (its mtime is seconds old) is never removed. A separate
+ // random suffix per writer keeps concurrent saves from colliding, which is why temps are not a fixed name
+ sweepTempFiles(hashPrefix, lockStaleMillis);
+ }
+
+ private void sweepTempFiles(String hashPrefix, long minAgeMillis) {
+ // shared by save()'s staleness-bounded sweep and clear()'s unconditional one (minAgeMillis 0), which
+ // must reclaim even a freshly orphaned temp because it holds a plaintext refresh token the caller has
+ // just asked to forget
+ try (DirectoryStream stream = Files.newDirectoryStream(directory, hashPrefix + "*.tmp")) {
+ final long now = System.currentTimeMillis();
+ for (Path tmp : stream) {
+ // never sweep a steal-captured lock (.lock..tmp): it is a cross-process steal in
+ // progress, not an orphaned write temp, and ATOMIC_MOVE preserves the stale lock's old mtime
+ // onto it, so the age guard below would judge an in-flight capture sweepable and delete it -
+ // destroying a lock the stealer may be about to restore to its live owner. A save write temp is
+ // .tmp and never contains ".lock.", so this only excludes captures. A capture
+ // orphaned by a crash mid-steal is rare and harmless (the canonical lock path is left free), so
+ // it is deliberately not reclaimed here.
+ if (tmp.getFileName().toString().contains(".lock.")) {
+ continue;
+ }
+ try {
+ if (now - Files.getLastModifiedTime(tmp).toMillis() >= minAgeMillis) {
+ Files.deleteIfExists(tmp);
+ }
+ } catch (IOException ignore) {
+ // best-effort; skip this entry and let a later sweep retry
+ }
+ }
+ } catch (IOException ignore) {
+ // best-effort; a sweep failure must never fail a save
+ }
+ }
+
+ private Path tokenFile(TokenStoreKey key) {
+ return directory.resolve(key.hash() + ".json");
+ }
+
+ /**
+ * One identity's in-process lock plus the number of callers currently holding or queued on it.
+ *
+ * {@code users} is read and written only inside {@link ConcurrentHashMap#compute} /
+ * {@link ConcurrentHashMap#computeIfPresent} remapping functions, which run under the bin lock, so it
+ * needs no volatility or atomics of its own.
+ */
+ private static final class ProcessLock {
+ final ReentrantLock lock = new ReentrantLock();
+ int users;
+ }
+
+ private static final class TokenFileParser implements JsonParser {
+ private static final int FIELD_ACCESS_TOKEN = 8;
+ private static final int FIELD_AUDIENCE = 6;
+ private static final int FIELD_CLIENT_ID = 2;
+ private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 4;
+ private static final int FIELD_EXPIRES_AT_MILLIS = 11;
+ private static final int FIELD_GROUPS_IN_TOKEN = 7;
+ private static final int FIELD_ID_TOKEN = 9;
+ private static final int FIELD_NONE = 0;
+ private static final int FIELD_REFRESH_TOKEN = 10;
+ private static final int FIELD_SCOPE = 5;
+ private static final int FIELD_TOKEN_ENDPOINT = 3;
+ private static final int FIELD_TOKEN_TTL_MILLIS = 12;
+ private static final int FIELD_VERSION = 1;
+ final StringSink accessToken = new StringSink();
+ final StringSink audience = new StringSink();
+ final StringSink clientId = new StringSink();
+ final StringSink deviceAuthorizationEndpoint = new StringSink();
+ final StringSink idToken = new StringSink();
+ final StringSink refreshToken = new StringSink();
+ final StringSink scope = new StringSink();
+ final StringSink tokenEndpoint = new StringSink();
+ long expiresAtMillis;
+ boolean groupsInToken;
+ long tokenTtlMillis;
+ long version;
+ private int depth;
+ private int field = FIELD_NONE;
+ private boolean malformed;
+
+ @Override
+ public void onEvent(int code, CharSequence tag, int position) {
+ switch (code) {
+ case JsonLexer.EVT_ARRAY_START:
+ // the on-disk schema is a single flat JSON object; an array anywhere (for example a
+ // top-level [ {..} ] wrapper) is a malformed or hostile shape - mark the document invalid
+ // rather than extract fields from it through the object-depth gate
+ malformed = true;
+ break;
+ case JsonLexer.EVT_OBJ_START:
+ depth++;
+ break;
+ case JsonLexer.EVT_OBJ_END:
+ depth--;
+ break;
+ case JsonLexer.EVT_NAME:
+ if (depth == 1) {
+ if (Chars.equals("v", tag)) {
+ field = FIELD_VERSION;
+ } else if (Chars.equals("client_id", tag)) {
+ field = FIELD_CLIENT_ID;
+ } else if (Chars.equals("token_endpoint", tag)) {
+ field = FIELD_TOKEN_ENDPOINT;
+ } else if (Chars.equals("device_authorization_endpoint", tag)) {
+ field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT;
+ } else if (Chars.equals("scope", tag)) {
+ field = FIELD_SCOPE;
+ } else if (Chars.equals("audience", tag)) {
+ field = FIELD_AUDIENCE;
+ } else if (Chars.equals("groups_in_token", tag)) {
+ field = FIELD_GROUPS_IN_TOKEN;
+ } else if (Chars.equals("access_token", tag)) {
+ field = FIELD_ACCESS_TOKEN;
+ } else if (Chars.equals("id_token", tag)) {
+ field = FIELD_ID_TOKEN;
+ } else if (Chars.equals("refresh_token", tag)) {
+ field = FIELD_REFRESH_TOKEN;
+ } else if (Chars.equals("expires_at_millis", tag)) {
+ field = FIELD_EXPIRES_AT_MILLIS;
+ } else if (Chars.equals("token_ttl_millis", tag)) {
+ field = FIELD_TOKEN_TTL_MILLIS;
+ } else {
+ field = FIELD_NONE;
+ }
+ }
+ break;
+ case JsonLexer.EVT_VALUE:
+ if (depth == 1) {
+ switch (field) {
+ case FIELD_VERSION:
+ // keep the full long: an over-32-bit value (e.g. 1 + 2^32) must not narrow to
+ // SCHEMA_VERSION and slip through the schema gate, so compare it as a long
+ version = parseLongOrZero(tag);
+ break;
+ case FIELD_CLIENT_ID:
+ putValue(clientId, tag);
+ break;
+ case FIELD_TOKEN_ENDPOINT:
+ putValue(tokenEndpoint, tag);
+ break;
+ case FIELD_DEVICE_AUTHORIZATION_ENDPOINT:
+ putValue(deviceAuthorizationEndpoint, tag);
+ break;
+ case FIELD_SCOPE:
+ putValue(scope, tag);
+ break;
+ case FIELD_AUDIENCE:
+ putValue(audience, tag);
+ break;
+ case FIELD_GROUPS_IN_TOKEN:
+ groupsInToken = Chars.equals("true", tag);
+ break;
+ case FIELD_ACCESS_TOKEN:
+ putValue(accessToken, tag);
+ break;
+ case FIELD_ID_TOKEN:
+ putValue(idToken, tag);
+ break;
+ case FIELD_REFRESH_TOKEN:
+ putValue(refreshToken, tag);
+ break;
+ case FIELD_EXPIRES_AT_MILLIS:
+ expiresAtMillis = parseLongOrZero(tag);
+ break;
+ case FIELD_TOKEN_TTL_MILLIS:
+ tokenTtlMillis = parseLongOrZero(tag);
+ break;
+ default:
+ break;
+ }
+ }
+ field = FIELD_NONE;
+ break;
+ default:
+ break;
+ }
+ }
+
+ private static void putValue(StringSink sink, CharSequence tag) {
+ // The writer omits a null/absent field entirely, so a value event means the field was present
+ // with a real string: store it verbatim, including a value that is literally "null". A bare JSON
+ // null in a hand-edited or non-conforming file lands here as "null" too, because JsonLexer
+ // reports the two identically - which is exactly why the frozen format forbids a writer from
+ // emitting one (design/oidc-token-persistence.md).
+ //
+ // Faithfully round-tripping whatever is on disk is this parser's job; deciding whether a value is
+ // fit to be a credential is not. OidcDeviceAuth.adopt() makes that call, and refuses a served
+ // token of "null" along with the blank and control-character ones, so a non-conforming writer
+ // degrades to an interactive sign-in rather than to a "Bearer null" header the server answers
+ // with 401. Nothing here rejects it: the fingerprint covers client_id, the endpoints, scope,
+ // audience and groups_in_token - never the token - and "null" is four printable ASCII characters,
+ // so the char check passes it too.
+ sink.clear();
+ sink.put(tag);
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java
new file mode 100644
index 000000000..92d0f1df6
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java
@@ -0,0 +1,119 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.DisplaySafe;
+import io.questdb.client.std.str.StringSink;
+
+/**
+ * Thrown when the OIDC device authorization flow cannot obtain a token. The message is built via
+ * the fluent {@link #put(CharSequence)} family, backed by a {@link StringSink}.
+ *
+ * For an OAuth error response (RFC 6749 / RFC 8628), {@link #getOauthError()} returns the
+ * machine-readable error code (e.g. {@code access_denied}, {@code expired_token}); else {@code null}.
+ */
+public class OidcAuthException extends RuntimeException {
+ private final StringSink message = new StringSink();
+ private String oauthError;
+
+ public OidcAuthException() {
+ }
+
+ public OidcAuthException(CharSequence message) {
+ this.message.put(message);
+ }
+
+ public OidcAuthException(Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * Builds an exception from an OAuth error response.
+ *
+ * @param error the OAuth {@code error} code, never null
+ * @param description the optional {@code error_description}, may be null or empty
+ * @return a new exception carrying the error code
+ */
+ public static OidcAuthException oauthError(CharSequence error, CharSequence description) {
+ OidcAuthException e = new OidcAuthException();
+ e.oauthError = error != null ? error.toString() : null;
+ e.put("the identity provider returned an error [error=").putSanitized(error);
+ if (description != null && description.length() > 0) {
+ e.put(", description=").putSanitized(description);
+ }
+ e.put(']');
+ return e;
+ }
+
+ // Whether a character must never reach a terminal or log line, delegated to the shared DisplaySafe
+ // classifier so the auth layer and Utf16Sink.putAsPrintable judge display safety identically. The
+ // argument is a code point, not a UTF-16 unit: putSanitized scans with codePointAt, which joins a
+ // surrogate pair into one code point, so a supplementary-plane format/control char is judged whole
+ // rather than as two harmless-looking halves (the gap that once let an invisible U+E00xx "tag" char
+ // through). A lone unpaired surrogate surfaces as a SURROGATE code point and is stripped too.
+ static boolean isUnsafeForDisplay(int c) {
+ return DisplaySafe.isUnsafeForDisplay(c);
+ }
+
+ @Override
+ public String getMessage() {
+ return message.toString();
+ }
+
+ public String getOauthError() {
+ return oauthError;
+ }
+
+ public OidcAuthException put(char ch) {
+ message.put(ch);
+ return this;
+ }
+
+ public OidcAuthException put(CharSequence cs) {
+ message.put(cs);
+ return this;
+ }
+
+ public OidcAuthException put(long value) {
+ message.put(value);
+ return this;
+ }
+
+ // appends untrusted text with display-unsafe chars stripped, so an attacker-influenced IdP error
+ // string cannot inject ANSI escapes, forge log lines, or smuggle bidi/zero-width formatting when
+ // the exception message is rendered
+ private void putSanitized(CharSequence cs) {
+ if (cs != null) {
+ for (int i = 0, n = cs.length(); i < n; ) {
+ final int cp = Character.codePointAt(cs, i);
+ final int count = Character.charCount(cp);
+ if (!isUnsafeForDisplay(cp)) {
+ message.put(cs, i, i + count);
+ }
+ i += count;
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java
new file mode 100644
index 000000000..6cac5eeb6
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java
@@ -0,0 +1,3059 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.ClientTlsConfiguration;
+import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.HttpClientConfiguration;
+import io.questdb.client.cutlass.http.HttpException;
+import io.questdb.client.cutlass.http.client.Fragment;
+import io.questdb.client.cutlass.http.client.HttpClient;
+import io.questdb.client.cutlass.http.client.HttpClientException;
+import io.questdb.client.cutlass.http.client.HttpClientFactory;
+import io.questdb.client.cutlass.http.client.Response;
+import io.questdb.client.cutlass.json.JsonException;
+import io.questdb.client.cutlass.json.JsonLexer;
+import io.questdb.client.cutlass.json.JsonParser;
+import io.questdb.client.std.Chars;
+import io.questdb.client.std.Misc;
+import io.questdb.client.std.Mutable;
+import io.questdb.client.std.Numbers;
+import io.questdb.client.std.NumericException;
+import io.questdb.client.std.Os;
+import io.questdb.client.std.QuietCloseable;
+import io.questdb.client.std.str.DirectUtf8Sequence;
+import io.questdb.client.std.str.StringSink;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.UnsupportedEncodingException;
+import java.net.InetAddress;
+import java.net.URLEncoder;
+import java.net.UnknownHostException;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Obtains an OIDC access or id token via the OAuth 2.0 Device Authorization Grant
+ * (RFC 8628), so a browserless process (remote notebook kernel, container, headless job)
+ * can sign a human in: the user authorizes on any device while the token request travels
+ * outbound only.
+ *
+ * The token works on any auth path the server validates:
+ *
+ * - HTTP {@code Authorization: Bearer } (REST {@code /exec}, or the ingestion
+ * {@link io.questdb.client.Sender} via {@code httpToken});
+ * - PG-wire: connect as user {@code _sso} with the token as the password
+ * (requires {@code acl.oidc.pg.token.as.password.enabled=true} on the server).
+ *
+ * Typical use, discovering everything from the QuestDB server:
+ * {@code
+ * try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
+ * String token = auth.signIn(); // signs in on first use, then caches and refreshes
+ * // ... use token as an HTTP Bearer header or a PG-wire _sso password ...
+ * }
+ * }
+ * Or configuring the identity provider explicitly:
+ * {@code
+ * 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)
+ * .build();
+ * }
+ * {@link #signIn()} serves a cached token while valid, silently refreshes when a refresh token
+ * exists, otherwise re-runs the interactive flow. An instance lock serializes calls, so two
+ * sign-ins never start at once. A sign-in waiting for the user holds that lock for the device code
+ * lifetime (up to 30 minutes), so a concurrent {@link #signIn()} or {@link #clearCache()} blocks
+ * behind it - but {@link #getToken()} never waits behind an interactive sign-in: it fails fast with an
+ * {@link OidcAuthException} rather than stall a request/flush path (a needed silent refresh still runs,
+ * each HTTP round-trip phase bounded by {@link Builder#httpTimeoutMillis(int)}, and the TCP connect and TLS
+ * handshake bounded by it too - though DNS resolution is still the OS's to bound - plus, with a
+ * coordinating {@link TokenStore}, a brief
+ * cross-process lock wait; see {@link #getToken()}). To abort a waiting sign-in, call
+ * {@link #close()} from another thread; it signals the flow to stop, which then fails with an
+ * {@link OidcAuthException} rather than polling until the device code expires. Cancellation is seen
+ * between polls (within ~100ms while waiting out an interval); a poll already in flight is not
+ * interrupted, so the abort - and {@link #close()} - can take up to one HTTP request timeout (see
+ * {@link Builder#httpTimeoutMillis(int)}), still far short of the device-code lifetime (a
+ * {@link DeviceCodePrompt} that blocks in {@code promptUser}, such as the default browser launch, can
+ * extend that wait by however long it runs).
+ *
+ * Instances are interactive and hold a network connection; close them when done. Token state is
+ * in-memory only by default; pass a {@link TokenStore} (via {@link Builder#tokenStore(TokenStore)} or
+ * {@link DiscoveryOptions#tokenStore(TokenStore)}) to persist it across process restarts, so a restarted
+ * process resumes from a saved refresh token instead of running the interactive flow again.
+ */
+public class OidcDeviceAuth implements QuietCloseable {
+ public static final String DEFAULT_SCOPE = "openid";
+ static final String GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code";
+ static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token";
+ // fixed clock-skew margin (matches the Python client questdb.auth): getToken() treats a cached token as
+ // expired this many millis before its real exp, to absorb clock drift and request latency. Not
+ // configurable; effectiveSkewMillis() caps it at half the token lifetime so a short-lived token is not
+ // reported expired the instant it is issued.
+ private static final long CLOCK_SKEW_MILLIS = 30_000L;
+ // device code TTL when the device authorization response omits (or zeroes) expires_in; matches Python
+ private static final int DEFAULT_DEVICE_CODE_TTL_SECONDS = 600;
+ private static final int DEFAULT_HTTP_TIMEOUT_MILLIS = 30_000;
+ private static final int DEFAULT_POLL_INTERVAL_SECONDS = 5;
+ // token cache TTL when the token response omits expires_in
+ private static final int DEFAULT_TOKEN_TTL_SECONDS = 300;
+ // The config the DISCOVERY clients take. Discovery runs off DEFAULT_HTTP_TIMEOUT_MILLIS rather than a
+ // builder value, because it happens before there is an instance to carry one. See httpConfig().
+ private static final HttpClientConfiguration DISCOVERY_HTTP_CONFIG = httpConfig(DEFAULT_HTTP_TIMEOUT_MILLIS);
+ private static final String ERROR_AUTHORIZATION_PENDING = "authorization_pending";
+ private static final String ERROR_SLOW_DOWN = "slow_down";
+ // getToken() polls for the instance lock in slices this small so it observes an interactive sign-in that
+ // starts while it waits (and close()) promptly, rather than blocking a whole refresh behind a single
+ // acquire; see acquireForGetToken()
+ private static final long GET_TOKEN_LOCK_POLL_SLICE_MILLIS = 50;
+ // the grant_type values are constants, so url-encode them once at class load rather than on every
+ // device-code poll and token refresh
+ private static final String GRANT_TYPE_DEVICE_CODE_ENCODED = urlEncode(GRANT_TYPE_DEVICE_CODE);
+ private static final String GRANT_TYPE_REFRESH_TOKEN_ENCODED = urlEncode(GRANT_TYPE_REFRESH_TOKEN);
+ // a rate-limited identity provider answers 429; the token poll treats it as a transient backoff
+ private static final String HTTP_STATUS_TOO_MANY_REQUESTS = "429";
+ // Token responses carry JWTs (an id token with group claims can be several KB), and a single
+ // value may arrive split across HTTP fragments. The lexer stashes a split value and rejects it
+ // past JSON_LEXER_MAX_VALUE_BYTES, so the limit must comfortably exceed any real token or large
+ // tokens fail to parse with "String is too long".
+ private static final int JSON_LEXER_CACHE_SIZE = 1024;
+ private static final int JSON_LEXER_MAX_VALUE_BYTES = 1 << 20;
+ // the I/O portion of a coordinated refresh, as a multiple of httpTimeoutMillis: the refresh under the lock
+ // runs send + await + parse, plus a body drain on a parse failure, each separately bounded by
+ // httpTimeoutMillis. The connection phase that precedes the send is bounded by httpTimeoutMillis too -
+ // httpConfig() derives the TCP connect timeout and the TLS handshake budget from it - so the only part of a
+ // hold this multiple does not account for is DNS resolution, which the OS bounds. build() requires the
+ // FileTokenStore staleness window to exceed this multiple as a floor (see build())
+ private static final int LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE = 4;
+ private static final Logger LOG = LoggerFactory.getLogger(OidcDeviceAuth.class);
+ // upper bound on the device code lifetime (the device authorization response's expires_in), so a
+ // hostile or buggy provider cannot make the client poll for an absurd duration; matches the Python client
+ private static final int MAX_DEVICE_CODE_TTL_SECONDS = 1800;
+ /**
+ * Floor on how often {@link #getToken()} will re-attempt a silent refresh after one failed. Without it a
+ * revoked refresh token, or an IdP outage, cost a full token-endpoint round trip on EVERY call - and
+ * getToken() is called once per ILP flush and once per WebSocket (re)connect, so a producer retrying its
+ * rows drove a sustained request flood at the identity provider (enough to trip its rate limits and
+ * lengthen the very outage being retried) while each call blocked the producer for the round trip, up to
+ * httpTimeoutMillis against a black-holed endpoint.
+ *
+ * Deliberately short: this is a stampede guard, not a circuit breaker. A credential that comes back
+ * within seconds is picked up on the next call, and any explicit {@link #signIn()} or
+ * {@link #clearCache()} clears the latch outright.
+ */
+ private static final long MIN_REFRESH_RETRY_INTERVAL_MILLIS = 5_000L;
+ // First non-zero back-off between token store reads; it doubles per consecutive failure, up to
+ // MAX_STORE_LOAD_RETRY_INTERVAL_MILLIS. Same floor as the refresh back-off above, and the same kind of
+ // stampede guard. The FIRST failure arms a ZERO-length back-off, so the very next call still re-reads the
+ // store: a one-shot fault - notably a carried interrupt flag, which makes the InterruptibleChannel under
+ // FileTokenStore throw on a thread that merely carries it - must recover on the next call rather than wait
+ // this out. Anything that survives that free retry needs an operator (a chmod, a remount), so waiting is
+ // no longer costing a recovery that was about to happen anyway.
+ private static final long MIN_STORE_LOAD_RETRY_INTERVAL_MILLIS = 5_000L;
+ // upper bound on the token cache lifetime (the token response's expires_in), so an absurd or hostile
+ // value cannot overflow the timing arithmetic or make the client trust a token for absurdly long
+ private static final int MAX_EXPIRES_IN_SECONDS = 3600;
+ // upper bound on the configurable HTTP request timeout. A token-endpoint round-trip never needs longer, and
+ // bounding it keeps the I/O portion of a refresh held under the FileTokenStore cross-process lock (send +
+ // await + parse, plus a body drain on a parse failure - each separately bounded by this, so up to ~4x this)
+ // a known, bounded multiple, so the store's staleness window can be sized to dominate it. The connection
+ // phase is bounded by this too - httpConfig() derives the connect timeout and the TLS handshake budget
+ // from the same figure - leaving only DNS resolution to the OS (see Builder.build())
+ private static final int MAX_HTTP_TIMEOUT_MILLIS = 120_000;
+ // upper bound on the poll interval, both the initial value and the growth after a slow_down or 429, so
+ // a hostile or buggy provider cannot stall the poll loop; matches the Python client
+ private static final int MAX_POLL_INTERVAL_SECONDS = 60;
+ // cap bytes drained per response so a hostile/MITM'd server cannot stream an endless body and
+ // wedge the thread; far above any real OIDC JSON response
+ private static final int MAX_RESPONSE_BODY_BYTES = 4 * 1024 * 1024;
+ // Ceiling on the back-off between token store reads. maybeLoadFromStore() runs on the getToken() path,
+ // which an ILP producer calls once per flush, so a store that is permanently unreadable - a chmod or uid
+ // mismatch in a container, EIO/ESTALE on an NFS home - otherwise cost a blocking file open, two exception
+ // fills and a WARN line on EVERY flush, on the producer thread and under this instance's lock. A store
+ // that simply has nothing to return is unaffected: load() reports that by returning null rather than by
+ // throwing, and that latches storeLoadAttempted outright.
+ private static final long MAX_STORE_LOAD_RETRY_INTERVAL_MILLIS = 60_000L;
+ private static final int POLL_PENDING = 1;
+ private static final long POLL_SLEEP_SLICE_MILLIS = 100;
+ private static final int POLL_SLOW_DOWN = 2;
+ private static final int POLL_SUCCESS = 0;
+ private static final int POLL_TRANSIENT_ERROR = 3;
+ private static final int SLOW_DOWN_INCREMENT_SECONDS = 5;
+ private static final String USER_AGENT = "questdb/java-client-oidc";
+ private static final String WELL_KNOWN_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration";
+ private final String audienceEncoded;
+ // This instance's HTTP transport budgets, derived from httpTimeoutMillis. See httpConfig().
+ private final HttpClientConfiguration clientConfig;
+ private final String clientIdEncoded;
+ private final DeviceAuthorizationResponseParser deviceAuthParser = new DeviceAuthorizationResponseParser();
+ private final Endpoint deviceAuthorizationEndpoint;
+ private final StringSink formSink = new StringSink();
+ private final boolean groupsInToken;
+ private final int httpTimeoutMillis;
+ // serializes signIn()/getToken()/clearCache()/close(); signIn() holds it for the whole
+ // interactive flow, getToken() uses tryLock so the flush path never stalls behind a sign-in
+ private final ReentrantLock lock = new ReentrantLock();
+ private final DeviceCodePrompt prompt;
+ private final StringSink responseStatus = new StringSink();
+ private final String scopeEncoded;
+ private final TokenStoreKey storeKey;
+ private final ClientTlsConfiguration tlsConfig;
+ private final Endpoint tokenEndpoint;
+ private final TokenResponseParser tokenParser = new TokenResponseParser();
+ private final TokenStore tokenStore;
+ private String accessToken;
+ private volatile boolean closed;
+ private long expiresAtMillis;
+ private String idToken;
+ // set only while signIn() runs the interactive device flow (holding the lock for up to the device-code
+ // lifetime). getToken() reads it lock-free to fail fast behind an interactive sign-in while still waiting
+ // briefly behind a peer's quick silent refresh; volatile for that cross-thread read. See acquireForGetToken()
+ private volatile boolean interactiveSignInInProgress;
+ private JsonLexer jsonLexer;
+ private String lastPersistedRefreshToken;
+ // earliest wall-clock millis at which maybeLoadFromStore() may re-read the store after a read threw; 0
+ // until the first failure. See isStoreLoadBackedOff()
+ private long nextStoreLoadAttemptMillis;
+ private HttpClient plainClient;
+ private long refreshFailedAtMillis;
+ private String refreshToken;
+ private boolean storeLoadAttempted;
+ // back-off applied to the NEXT failed store read, doubling from MIN_ to MAX_STORE_LOAD_RETRY_INTERVAL_MILLIS;
+ // 0 while no read has failed yet, which is what makes the first retry immediate
+ private long storeLoadRetryIntervalMillis;
+ private HttpClient tlsClient;
+ // lifetime in millis of the currently cached token (its clamped TTL); effectiveSkewMillis() caps the
+ // clock skew at half of this so a short-lived token is not treated as expired the instant it is issued
+ private long tokenTtlMillis;
+
+ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig, Endpoint deviceAuthorizationEndpoint, Endpoint tokenEndpoint) {
+ String clientId = builder.clientId;
+ // pre-encode the invariant form params once here, so the poll loop and silent refresh do not
+ // re-run URLEncoder on every request (mirrors the pre-encoded GRANT_TYPE_* constants)
+ this.clientIdEncoded = urlEncode(clientId);
+ // build() already parsed and validated these endpoints; reuse them rather than re-parse the raw strings
+ this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
+ this.tokenEndpoint = tokenEndpoint;
+ String scope = builder.scope;
+ this.scopeEncoded = urlEncode(scope);
+ String audience = builder.audience;
+ this.audienceEncoded = audience != null ? urlEncode(audience) : null;
+ this.groupsInToken = builder.groupsInToken;
+ this.httpTimeoutMillis = builder.httpTimeoutMillis;
+ // Derive the transport budgets from the SAME figure the rest of the class quotes, so the connection
+ // phase is bounded by it too rather than by the 600s HttpClientConfiguration default (TLS) and the
+ // OS (TCP connect). See httpConfig().
+ this.clientConfig = httpConfig(this.httpTimeoutMillis);
+ this.prompt = builder.prompt;
+ this.tlsConfig = tlsConfig;
+ this.tokenStore = builder.tokenStore;
+ // key any persisted token by the identity it belongs to, built before the native lexer alloc so a
+ // throw here cannot leak it. Canonicalise the endpoints (lower-case scheme/host, explicit port) and
+ // normalise an empty audience to null, so the hash matches across processes and language clients.
+ this.storeKey = tokenStore == null ? null : new TokenStoreKey(
+ clientId,
+ canonicalEndpoint(this.tokenEndpoint),
+ canonicalEndpoint(this.deviceAuthorizationEndpoint),
+ scope,
+ audience != null && !audience.isEmpty() ? audience : null,
+ this.groupsInToken
+ );
+ // allocate the native lexer last: urlEncode and the TokenStoreKey construction above can throw, and
+ // the half-built instance is never returned, so close() could not free an earlier alloc
+ this.jsonLexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Discovers the OIDC configuration from a running QuestDB server and builds an instance.
+ * Reads the public {@code /settings} endpoint (no auth) for the client id, scope, token
+ * endpoint, device authorization endpoint and groups-in-token mode.
+ *
+ * Trust model: the endpoints the user signs in against come from the server's
+ * unauthenticated {@code /settings} response, so a spoofed, compromised, or MITM'd server can
+ * redirect the whole sign-in to an attacker-controlled identity provider and harvest the
+ * authorization. Only call {@code fromQuestDB} against a trusted server reached over {@code https}
+ * (required by default; {@link Builder#allowInsecureTransport(boolean)} removes that protection).
+ * For an untrusted server, configure the identity provider explicitly with {@link #builder()}, or
+ * pin it via {@link #fromQuestDB(String, DiscoveryOptions)} and {@link DiscoveryOptions#issuer(String)}.
+ *
+ * @param questdbUrl the QuestDB HTTP base URL, for example {@code https://questdb.example.com:9000}
+ * @return a configured, ready-to-use instance
+ * @throws OidcAuthException if the server has OIDC disabled, or does not advertise a device
+ * authorization endpoint and no issuer was pinned to discover it
+ */
+ public static OidcDeviceAuth fromQuestDB(String questdbUrl) {
+ return fromQuestDB(questdbUrl, new DiscoveryOptions());
+ }
+
+ /**
+ * Discovers the OIDC configuration from a running QuestDB server, like {@link #fromQuestDB(String)},
+ * but with explicit {@link DiscoveryOptions}: an identity provider pin (issuer), a TLS configuration, an
+ * insecure-transport opt-in, and the device code prompt - for example
+ * {@link DeviceCodePrompt#openBrowser()} to also open the verification URL in a browser.
+ *
+ * @param questdbUrl the QuestDB HTTP base URL, for example {@code https://questdb.example.com:9000}
+ * @param options how to pin the identity provider, configure TLS, permit insecure transport, and
+ * show the device code challenge; see {@link DiscoveryOptions}
+ * @return a configured, ready-to-use instance
+ * @throws OidcAuthException if the server has OIDC disabled, or does not advertise a device
+ * authorization endpoint and no issuer was pinned
+ */
+ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions options) {
+ String issuer = options.issuer;
+ ClientTlsConfiguration tlsConfig = options.tlsConfig != null ? options.tlsConfig : defaultTlsConfig();
+ boolean allowInsecureTransport = options.allowInsecureTransport;
+ Endpoint server = Endpoint.parse(questdbUrl);
+ if (!allowInsecureTransport) {
+ requireSecureTransport(server.isTls, "QuestDB server url", questdbUrl);
+ }
+ SettingsDiscoveryParser parser = new SettingsDiscoveryParser();
+ discoverSettings(server, tlsConfig, parser);
+ if (!parser.isOidcEnabled) {
+ throw new OidcAuthException().put("OIDC is not enabled on the QuestDB server [url=").put(questdbUrl).put(']');
+ }
+ if (parser.clientId.length() == 0) {
+ throw new OidcAuthException().put("the QuestDB server does not advertise an OIDC client id [url=").put(questdbUrl).put(']');
+ }
+ String tokenEndpoint = parser.tokenEndpoint.length() > 0 ? parser.tokenEndpoint.toString() : null;
+ String deviceAuthorizationEndpoint = parser.deviceAuthorizationEndpoint.length() > 0 ? parser.deviceAuthorizationEndpoint.toString() : null;
+ String resolvedIssuer = issuer != null && !issuer.isEmpty() ? issuer : null;
+ // capture each endpoint's provenance before discovery may fill a missing one: only an endpoint the
+ // untrusted /settings response advertised is origin-pinned to the issuer below. An endpoint discovered
+ // from the provider's own .well-known is authoritative for wherever the pinned issuer hosts it.
+ final boolean tokenEndpointFromSettings = tokenEndpoint != null;
+ final boolean deviceEndpointFromSettings = deviceAuthorizationEndpoint != null;
+
+ // Over a plaintext, MITM-able http /settings channel (only reachable with allowInsecureTransport;
+ // the default rejects it), advertised endpoints can be tampered in transit to route the device
+ // code and long-lived refresh token to an attacker. The missing-endpoint discovery path below
+ // already demands an out-of-band pin, but a tampered /settings advertising BOTH endpoints at one
+ // attacker origin skips that path - the co-location check passes trivially and there is no issuer
+ // to pin against - so require the same pin before trusting /settings endpoints over such a channel.
+ boolean settingsSuppliedCredentials = tokenEndpoint != null || deviceAuthorizationEndpoint != null;
+ if (settingsSuppliedCredentials && resolvedIssuer == null && settingsChannelIsPlaintext(server)) {
+ throw new OidcAuthException()
+ .put("the QuestDB server was reached over insecure http, so its /settings response - and the OIDC ")
+ .put("endpoints it advertises - can be tampered in transit and used to redirect the device-code and ")
+ .put("refresh-token requests to an attacker; pin the identity provider with an issuer (its origin, for ")
+ .put("example https://your-idp), configure the endpoints explicitly with OidcDeviceAuth.builder(), or ")
+ .put("connect to QuestDB over https [url=").put(questdbUrl).put(']');
+ }
+
+ // For /settings-supplied endpoints with an out-of-band issuer, require each under the issuer's PATH,
+ // not just its origin (validateEndpointOrigins): a path-based identity provider shares one origin per
+ // tenant (Keycloak issuers are https://host/realms/), so the origin check alone cannot stop a
+ // tampered /settings from steering credentials to a different realm. The issuer is supplied out of
+ // band and cannot be forged. Endpoints discovered from the identity provider below are not scoped this
+ // way - some providers (for example Azure AD) place their endpoints outside the issuer path.
+ if (issuer != null && !issuer.isEmpty()) {
+ if (tokenEndpoint != null && !isEndpointUnderIssuerPath(tokenEndpoint, issuer)) {
+ throw endpointNotUnderIssuer("token endpoint", tokenEndpoint, issuer);
+ }
+ if (deviceAuthorizationEndpoint != null && !isEndpointUnderIssuerPath(deviceAuthorizationEndpoint, issuer)) {
+ throw endpointNotUnderIssuer("device authorization endpoint", deviceAuthorizationEndpoint, issuer);
+ }
+ }
+
+ // Fall back to identity provider discovery when the server omits the device authorization endpoint
+ // (and/or the token endpoint). The provider's origin must be pinned out of band: the discovery
+ // target is never derived from a server-supplied value, else a tampered or intercepted /settings
+ // could steer discovery - and the credential POSTs - to an attacker while the co-location and
+ // issuer checks pass trivially.
+ if (deviceAuthorizationEndpoint == null || tokenEndpoint == null) {
+ if (resolvedIssuer == null) {
+ throw new OidcAuthException()
+ .put("the QuestDB server did not advertise the OIDC device authorization endpoint (and/or the token ")
+ .put("endpoint), so it must be discovered from the identity provider, but the identity provider is not ")
+ .put("pinned; pass an issuer (its origin, for example https://your-idp) to OidcDeviceAuth.fromQuestDB so ")
+ .put("a tampered or intercepted /settings response cannot redirect the device-code and refresh-token ")
+ .put("requests to an attacker, or configure the endpoints explicitly with OidcDeviceAuth.builder() [url=")
+ .put(questdbUrl).put(']');
+ }
+ WellKnownDiscoveryParser doc = new WellKnownDiscoveryParser();
+ discoverFromIdp(resolvedIssuer, tlsConfig, allowInsecureTransport, doc);
+ if (deviceAuthorizationEndpoint == null && doc.deviceAuthorizationEndpoint.length() > 0) {
+ deviceAuthorizationEndpoint = doc.deviceAuthorizationEndpoint.toString();
+ }
+ if (tokenEndpoint == null && doc.tokenEndpoint.length() > 0) {
+ tokenEndpoint = doc.tokenEndpoint.toString();
+ }
+ }
+
+ // Pin the ORIGIN of any endpoint the untrusted /settings response advertised to the pinned issuer
+ // origin, so a tampered /settings cannot redirect the device code and refresh token to
+ // an attacker. An endpoint discovered from the identity provider's own .well-known is deliberately NOT
+ // origin-pinned: that document is fetched from the pinned origin and is authoritative for wherever the
+ // issuer hosts its endpoints - some providers (for example Google) serve the token and device endpoints
+ // from a different origin than the issuer. The co-location check (token and device share one origin)
+ // still applies to every endpoint, enforced by validateEndpointOrigins in build().
+ if (resolvedIssuer != null) {
+ Endpoint pin = Endpoint.parse(resolvedIssuer);
+ if (tokenEndpointFromSettings && !isSameOrigin(Endpoint.parse(tokenEndpoint), pin)) {
+ throw endpointOriginNotPinned("token endpoint", tokenEndpoint, originOf(pin));
+ }
+ if (deviceEndpointFromSettings && !isSameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) {
+ throw endpointOriginNotPinned("device authorization endpoint", deviceAuthorizationEndpoint, originOf(pin));
+ }
+ }
+
+ if (tokenEndpoint == null) {
+ throw new OidcAuthException()
+ .put("could not resolve the OIDC token endpoint from the QuestDB /settings response or the identity ")
+ .put("provider discovery document; configure it explicitly with OidcDeviceAuth.builder() [url=").put(questdbUrl).put(']');
+ }
+ if (deviceAuthorizationEndpoint == null) {
+ throw new OidcAuthException()
+ .put("could not resolve the device authorization endpoint; the identity provider discovery document did ")
+ .put("not advertise \"device_authorization_endpoint\". Ensure the identity provider supports the device ")
+ .put("grant, or configure the endpoint explicitly with OidcDeviceAuth.builder() [url=").put(questdbUrl).put(']');
+ }
+ return builder()
+ .clientId(parser.clientId.toString())
+ .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint)
+ .tokenEndpoint(tokenEndpoint)
+ .scope(parser.scope.length() > 0 ? parser.scope.toString() : DEFAULT_SCOPE)
+ .audience(parser.audience.length() > 0 ? parser.audience.toString() : null)
+ .groupsInToken(parser.groupsInToken)
+ .allowInsecureTransport(allowInsecureTransport)
+ .tlsConfig(tlsConfig)
+ .prompt(options.prompt)
+ .tokenStore(options.tokenStore)
+ .build();
+ }
+
+ /**
+ * Drops any cached token so the next {@link #signIn()} starts a fresh interactive sign-in.
+ */
+ public void clearCache() {
+ lock.lock();
+ try {
+ throwIfClosed();
+ // the same sweep close() runs: nulling the served token is not enough on its own, since the raw
+ // response and request bytes are still legible in the reusable sinks that carried them
+ wipeCredentialState();
+ expiresAtMillis = 0;
+ tokenTtlMillis = 0;
+ refreshFailedAtMillis = 0;
+ if (tokenStore != null) {
+ try {
+ tokenStore.clear(storeKey);
+ } catch (RuntimeException e) {
+ warnPersistence("clear", e);
+ }
+ }
+ // do not reload the entry we just removed on the next signIn()/getToken()
+ storeLoadAttempted = true;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Frees the network connections and native buffers this instance holds. If a {@link #signIn()}
+ * sign-in is in flight on another thread, signals it to stop so it fails with an
+ * {@link OidcAuthException} instead of polling until the device code expires. The signal is observed
+ * between polls (within ~100ms while waiting out a poll interval); a poll request already in flight
+ * is not interrupted, so {@code close()} acquires the lock - and returns - only once that request
+ * finishes or times out, not the full device-code lifetime. That bound is the in-flight operation's own
+ * worst case, which is NOT a single HTTP request timeout: a silent refresh under the lock runs a send, an
+ * await and a body parse (each bounded by {@link Builder#httpTimeoutMillis(int)}), and its connection phase -
+ * TCP connect, TLS handshake - is bounded by that timeout as well, leaving only DNS resolution to the
+ * OS, so a black-holed token endpoint can hold the lock, and this {@code close()}, for roughly that (on
+ * Linux) rather than a single httpTimeoutMillis. The exception is a
+ * {@link DeviceCodePrompt} that blocks in {@code promptUser} - for example the default
+ * {@link DeviceCodePrompt#openBrowser()} prompt while it hands the verification URL to the OS browser,
+ * which is not bounded by the HTTP timeout: the flow holds the lock across that one-off prompt, so a
+ * racing {@code close()} waits it out too. Idempotent. After close, {@link #signIn()},
+ * {@link #getToken()} and {@link #clearCache()} throw.
+ */
+ @Override
+ public void close() {
+ // flag cancellation before taking the lock: signIn() holds it for the whole flow, so signal the
+ // in-flight sign-in to stop via a lock-free volatile write, then acquire the lock - released by the
+ // cancelled flow once it observes the flag (between polls, or after an in-flight poll returns) - and
+ // free the native resources. close() never frees while a flow holds the lock, so no use-after-free
+ closed = true;
+ lock.lock();
+ try {
+ // Drop the credential material FIRST. Every token operation is already refused by the closed flag
+ // above, so nothing here can be needed again - and nulling a String or overwriting a sink cannot
+ // throw, whereas an HttpClient close conceivably can, so doing it first means a failing free
+ // cannot leave a refresh token legible in this instance for the rest of the JVM's life.
+ wipeCredentialState();
+ // free the native lexer first: its close() is a bare Unsafe.free that cannot throw, whereas an
+ // HttpClient close conceivably could - freeing the native buffer first means such a throw cannot
+ // strand it (Misc.free nulls each field, so a second close() is still a safe no-op)
+ jsonLexer = Misc.free(jsonLexer);
+ plainClient = Misc.free(plainClient);
+ tlsClient = Misc.free(tlsClient);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * @return {@code "Bearer " + signIn()}, ready to use as the value of an HTTP
+ * {@code Authorization} header.
+ */
+ public String getAuthorizationHeaderValue() {
+ return "Bearer " + signIn();
+ }
+
+ /**
+ * Like {@link #signIn()} but never starts the interactive device flow, never prompts, and never waits
+ * on interactive input: returns the cached token while valid, silently refreshes when a refresh token is
+ * available, otherwise throws. Designed for the request/flush path of a long-lived client, for example
+ * {@code Sender.builder(...).httpTokenProvider(auth::getToken)}, where an interactive prompt is
+ * inappropriate. Call {@link #signIn()} once to sign in first.
+ *
+ * It does not wait behind an interactive {@link #signIn()} running on another thread (which would stall
+ * the flush for the whole device-code lifetime): if such a sign-in holds the lock it fails fast, and the
+ * caller should retry once the sign-in completes. It does, however, wait briefly behind another thread's
+ * quick cached read or silent refresh rather than fail every concurrent caller sharing this instance on
+ * each token refresh - the {@code HttpTokenProvider} contract permits that bounded wait, capped here at
+ * FOUR times {@link Builder#httpTimeoutMillis(int)} (two minutes at the 30s default), which is the
+ * holder's own worst case: a silent refresh under the lock runs a send, an await and a body parse, each
+ * separately bounded by that timeout, so a peer waiting only one would fail every concurrent caller
+ * behind a refresh that was going to succeed. Size flush backpressure against the four-times figure, not
+ * against {@code httpTimeoutMillis} itself. It still fails fast the moment an interactive sign-in or
+ * {@link #close()} begins meanwhile. It is not, otherwise, instantaneous - when the cached
+ * token has expired it makes one synchronous refresh round-trip to the token endpoint (and, with a
+ * coordinating {@link TokenStore}, may first wait to acquire the store's per-identity lock before that
+ * round-trip). For {@link FileTokenStore} the CROSS-process file lock is bounded to a few seconds and then
+ * proceeds without it; but the IN-process lock that serializes two instances sharing one identity in the
+ * same JVM (an ILP {@code Sender} and a {@code QwpQueryClient}, say) is not time-bounded, so such a
+ * concurrent caller instead waits out the peer's whole refresh - itself bounded only by the OS connect
+ * stall described next, not by a few seconds.
+ * The send, response wait and body parse of that round-trip are each bounded by
+ * {@link Builder#httpTimeoutMillis(int)} (30s by default); the connection phase that precedes them - DNS
+ * the TCP connect and the TLS handshake - is bounded by httpTimeoutMillis as well, since httpConfig()
+ * derives the connect timeout and the handshake budget from it. Only DNS resolution is left to the OS, so
+ * an unreachable (black-holed) token endpoint stalls this refresh for about the configured timeout rather
+ * than the OS TCP-connect timeout. That is the "quick silent refresh" the {@code HttpTokenProvider}
+ * contract permits on the flush path, not an unbounded interactive wait.
+ *
+ * @return a non-null, non-empty token
+ * @throws OidcAuthException if no token has been obtained yet, if the cached token expired and could
+ * not be refreshed without an interactive sign-in, if an interactive sign-in is
+ * in progress on another thread, or if a concurrent refresh did not complete in time
+ */
+ public String getToken() {
+ throwIfClosed();
+ acquireForGetToken();
+ try {
+ throwIfClosed();
+ maybeLoadFromStore();
+ final String cachedToken = groupsInToken ? idToken : accessToken;
+ if (cachedToken != null && System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
+ return cachedToken;
+ }
+ // The served-kind token is absent or expired. Try a silent refresh whenever a refresh token is
+ // available - including the case where a prior grant returned only the OTHER kind, leaving the served
+ // kind null: a refresh may yield the served kind and avoid forcing an interactive sign-in. selectToken()
+ // reports a clear error if the refresh still did not produce the kind the server expects.
+ // Back off after a failed refresh instead of re-attempting on every call. getToken() runs once
+ // per ILP flush and once per (re)connect, and a producer retrying rows calls it in a tight loop,
+ // so a revoked token or an unreachable IdP otherwise meant one full token-endpoint round trip per
+ // attempt - a request flood at the provider, and a producer blocked for each round trip. The
+ // failure is still reported on every call; only the network attempt is rate-limited.
+ // A caller whose thread is already interrupted is cancelled, and a silent refresh is a network
+ // round trip - exactly the work a cancellation is trying to stop. Decline it here, once, for
+ // three reasons the old shape got wrong:
+ //
+ // - the only interrupt guard was inside FileTokenStore.inLock, so this held ONLY when a token
+ // store was configured. Without one, tryRefreshCoordinated() went straight to tryRefresh()
+ // and POSTed to the token endpoint on a cancelled thread.
+ // - when the store's guard did decline, the fall-through reported "the cached token expired
+ // and could not be refreshed without an interactive sign-in; call signIn()". The endpoint was
+ // reachable and the lock free; the caller's own interrupt was the reason. That sends a user
+ // to re-authenticate over a credential that is fine.
+ // - it then latched the refresh back-off below, so one interrupt-carrying caller suppressed
+ // the next five seconds of legitimate refreshes for every thread sharing this instance.
+ //
+ // isInterrupted(), never interrupted(): the flag is the caller's cancellation signal and must
+ // survive this call, exactly as FileTokenStore.load()/save() preserve it.
+ if (refreshToken != null && Thread.currentThread().isInterrupted()) {
+ throw new OidcAuthException("the calling thread is interrupted, so no silent token refresh was attempted; retry on an uninterrupted thread");
+ }
+ // Arm the latch ONLY when a refresh was actually attempted and failed. Stamping it on a call
+ // that the back-off itself skipped slides the window forward by one call every time, so it
+ // never expires for a caller that returns faster than MIN_REFRESH_RETRY_INTERVAL_MILLIS - and
+ // getToken() runs once per ILP flush, at a default auto-flush interval of one second. One
+ // transient refresh failure then wedges the sender for the life of the process, long after the
+ // identity provider recovered, which is a circuit breaker rather than the stampede guard this
+ // is documented to be. maybeLoadFromStore() arms its sibling back-off inside the catch for the
+ // same reason: only a real attempt may re-arm.
+ if (refreshToken != null && !isRefreshBackedOff()) {
+ if (tryRefreshCoordinated()) {
+ refreshFailedAtMillis = 0;
+ return selectToken();
+ }
+ // A coordinating TokenStore returns false WITHOUT running the refresh when an interrupt
+ // abandons its lock wait, and the contract requires it to leave the flag set so this call
+ // can tell that apart from a refresh that ran and failed. The difference matters twice
+ // over: refreshFailedAtMillis is INSTANCE state, so arming it here would make one
+ // cancelled caller fail every other producer sharing this OidcDeviceAuth for the next
+ // MIN_REFRESH_RETRY_INTERVAL_MILLIS - over a credential that is fine and an identity
+ // provider that is reachable - and the message below would send that operator to
+ // re-authenticate for the same reason. Report the cancellation instead, and leave the
+ // latch alone: nothing was attempted, so there is nothing to back off from.
+ if (Thread.currentThread().isInterrupted()) {
+ throw new OidcAuthException("the calling thread was interrupted while waiting for the token store lock, so no silent token refresh was attempted; retry on an uninterrupted thread");
+ }
+ refreshFailedAtMillis = System.currentTimeMillis();
+ }
+ if (cachedToken != null) {
+ throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call signIn() to sign in again");
+ }
+ throw new OidcAuthException("no token has been obtained yet; call signIn() to sign in before using getToken()");
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Returns a valid token to present to QuestDB: the cached token while still valid, otherwise a
+ * silent refresh when possible, otherwise the interactive device flow. The token is the id token
+ * when the server expects groups encoded in the token, the access token otherwise.
+ *
+ * @return a non-null, non-empty token
+ * @throws OidcAuthException if the interactive flow fails, times out, the identity provider does not
+ * return the expected token, or the calling thread carries an interrupt and
+ * no valid cached token is available - a cancelled caller is not sent through
+ * a silent refresh or a device flow, both of which are network work
+ */
+ public String signIn() {
+ lock.lock();
+ try {
+ throwIfClosed();
+ // signIn() is an explicit user action, and it is about to spend a whole interactive device flow -
+ // so it is never the caller the store-read back-off exists to throttle. Clear that back-off, for
+ // the same reason the refresh back-off is cleared further down: a store that has become readable
+ // again must be re-read here, rather than have a human sent through the device flow over a refresh
+ // token that is sitting on disk.
+ nextStoreLoadAttemptMillis = 0;
+ storeLoadRetryIntervalMillis = 0;
+ maybeLoadFromStore();
+ // only the kind of token signIn() actually serves counts as a cache hit; a grant that
+ // returned the other kind (access token when the server wants the id token, or vice versa)
+ // leaves the served token null, so fall through rather than report the unusable grant as valid
+ // and have selectToken() throw on this and every later call
+ final String cachedToken = groupsInToken ? idToken : accessToken;
+ if (cachedToken != null && System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
+ return cachedToken;
+ }
+ // A cached token needs no network and was served above whatever the caller's state; everything
+ // from here on is a network round trip, which is the work a cancellation is trying to stop. So
+ // decline it, for the same reason and with the same test getToken() applies further up.
+ //
+ // Checked HERE rather than left to the store, because leaving it there made the outcome depend
+ // on whether a TokenStore was configured, and the two branches were wrong in OPPOSITE
+ // directions. With no store, tryRefreshCoordinated() went straight to tryRefresh() and POSTed to
+ // the token endpoint on a cancelled thread. With a FileTokenStore, inLock declined the carried
+ // interrupt by returning false - which reads here as "the refresh failed", so signIn() skipped a
+ // refresh it could have completed and started the DEVICE FLOW instead: a human prompt and a poll
+ // loop that is far more work than the round trip just declined, and that runs to the device-code
+ // lifetime (up to MAX_DEVICE_CODE_TTL_SECONDS) because sleepBetweenPolls uses Os.sleep, which
+ // ignores interrupts. A caller who cancelled got a browser prompt and a thread parked for half
+ // an hour.
+ //
+ // isInterrupted(), never interrupted(): the flag is the caller's cancellation signal and must
+ // survive this call, exactly as getToken() and FileTokenStore.load()/save() preserve it.
+ if (Thread.currentThread().isInterrupted()) {
+ throw new OidcAuthException("the calling thread is interrupted, so no sign-in was attempted; retry on an uninterrupted thread");
+ }
+ // Spend a silent refresh before prompting, whenever a refresh token is available - the same rule
+ // getToken() applies. That deliberately includes a null served kind: a restored entry whose grant
+ // only ever produced the OTHER kind still carries a usable refresh token, and one round-trip
+ // beats sending a human back through the device flow. Gating this on cachedToken != null, as it
+ // used to, meant such an entry always re-prompted. A refresh that does not yield the served kind
+ // returns false and falls straight through to the flow below, so this costs at most one wasted
+ // request and cannot loop.
+ // No back-off here, and the latch is cleared either way: signIn() is an explicit user action, it
+ // falls through to the interactive flow when the refresh fails, and it is exactly the call a user
+ // makes to recover from the failure getToken() is backing off from.
+ refreshFailedAtMillis = 0;
+ if (refreshToken != null && tryRefreshCoordinated()) {
+ return selectToken();
+ }
+ // Re-check the flag, because the guard on entry only covers an interrupt the caller ARRIVED with.
+ // tryRefreshCoordinated() above is a network round trip - up to four times httpTimeoutMillis plus
+ // an OS connect stall - and a cancellation landing inside it is the common case, not a narrow
+ // race: it is precisely when a refresh is failing that a caller gives up. Proceeding would then
+ // launch a browser and park for the device-code lifetime on a thread whose owner has already
+ // asked it to stop.
+ throwIfInterrupted("the calling thread was interrupted before the interactive sign-in started");
+ // flag the interactive phase so a concurrent getToken() fails fast (rather than waiting behind this
+ // for the whole device-code lifetime); it still waits behind the cheap cache/refresh work above
+ interactiveSignInInProgress = true;
+ try {
+ runDeviceFlow();
+ } finally {
+ interactiveSignInInProgress = false;
+ }
+ return selectToken();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private static String appendSettingsPath(String basePath) {
+ String trimmed = basePath;
+ while (trimmed.length() > 1 && trimmed.charAt(trimmed.length() - 1) == '/') {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ return "/".equals(trimmed) ? "/settings" : trimmed + "/settings";
+ }
+
+ private static int boundedSeconds(int value, int defaultValue, int maxValue) {
+ if (value <= 0) {
+ return defaultValue;
+ }
+ return Math.min(value, maxValue);
+ }
+
+ private static String canonicalEndpoint(Endpoint endpoint) {
+ // scheme and host lower-cased, port explicit, path verbatim: a stable rendering that hashes to the
+ // same TokenStoreKey across processes and language clients sharing this identity
+ return (endpoint.isTls ? "https://" : "http://")
+ + endpoint.host.toLowerCase(Locale.ROOT) + ':' + endpoint.port + endpoint.path;
+ }
+
+ private static String[] decodePathSegments(String path) {
+ // Repeatedly percent-decode (a server or proxy may unescape more than once, so %252e%252e -> .. )
+ // and fold backslash to slash (some proxies do), then split into segments. Comparing these decoded
+ // segments, not the raw wire string, means an encoding the server later undoes cannot hide a "..".
+ String decoded = path;
+ for (int i = 0; i < 10; i++) { // bounded; a real path needs 0-1 passes
+ String next = percentDecodeOnce(decoded);
+ if (next.equals(decoded)) {
+ break;
+ }
+ decoded = next;
+ }
+ return decoded.replace('\\', '/').split("/", -1);
+ }
+
+ private static ClientTlsConfiguration defaultTlsConfig() {
+ return new ClientTlsConfiguration(null, null, ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL);
+ }
+
+ private static boolean discardBody(Response body, int timeoutMillis) {
+ // best-effort drain after a parse failure to keep the keep-alive connection usable; bounded like
+ // parseBody so a hostile server cannot wedge the thread here either. Returns true only when the body
+ // was fully drained (so the connection can be reused); returns false when the drain stopped early -
+ // on the deadline, the byte cap, or a transport error - leaving unconsumed bytes, so the caller must
+ // drop the connection rather than parse this response's leftovers on the next request.
+ final long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
+ long totalBytes = 0;
+ try {
+ while (true) {
+ final long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ return false;
+ }
+ Fragment fragment = body.recv((int) Math.max(1, Math.min(remainingNanos / 1_000_000L, Integer.MAX_VALUE)));
+ if (fragment == null) {
+ return true;
+ }
+ totalBytes += fragment.hi() - fragment.lo();
+ if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
+ return false;
+ }
+ }
+ } catch (HttpClientException ignore) {
+ return false;
+ }
+ }
+
+ private static void discoverFromIdp(String issuer, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport, WellKnownDiscoveryParser parser) {
+ // the issuer is pinned out of band (the caller guarantees it is non-null), so the server cannot choose
+ // where discovery - and the credential POSTs it resolves - are aimed
+ String url = wellKnownUrl(issuer);
+ Endpoint endpoint = Endpoint.parse(url);
+ requireSecureIdpEndpoint(endpoint, "OIDC issuer", url, allowInsecureTransport);
+ fetchJson(endpoint, endpoint.path, tlsConfig, parser,
+ "could not reach the identity provider to discover OIDC settings",
+ "could not parse the identity provider discovery document",
+ "the identity provider did not return an OIDC discovery document");
+ }
+
+ private static void discoverSettings(Endpoint server, ClientTlsConfiguration tlsConfig, SettingsDiscoveryParser parser) {
+ fetchJson(server, appendSettingsPath(server.path), tlsConfig, parser,
+ "could not reach the QuestDB server to discover OIDC settings",
+ "could not parse the QuestDB /settings response",
+ "the QuestDB server did not return its settings");
+ }
+
+ private static OidcAuthException endpointNotUnderIssuer(String label, String url, String issuer) {
+ return new OidcAuthException()
+ .put("the OIDC ").put(label).put(" advertised by the QuestDB /settings response (").put(url)
+ .put(") is not under the pinned issuer (").put(issuer).put("); refusing to send credentials to ")
+ .put("an endpoint outside the trusted issuer, for example a different realm on the same host; ")
+ .put("if the identity provider places its endpoints outside the issuer path, configure them ")
+ .put("explicitly with OidcDeviceAuth.builder()");
+ }
+
+ private static OidcAuthException endpointOriginNotPinned(String label, String url, String pinOrigin) {
+ return new OidcAuthException()
+ .put("the OIDC ").put(label).put(" advertised by the QuestDB /settings response (").put(url)
+ .put(") is not on the pinned identity-provider origin (").put(pinOrigin).put("); refusing to send ")
+ .put("credentials to an endpoint outside the trusted issuer. If the identity provider hosts its ")
+ .put("endpoints on a different origin than its issuer, configure them explicitly with ")
+ .put("OidcDeviceAuth.builder()");
+ }
+
+ private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) {
+ // A real OIDC endpoint path is plain ASCII with no percent-encoding and no backslash, so reject either
+ // outright rather than try to out-decode the server. Percent-encoding is exactly where a tampered
+ // /settings hides a path separator ('/', '\') or a '..' that only surfaces once the server unescapes -
+ // and not only the forms this client's byte-oriented percentDecodeOnce resolves (%2f, %5c, %25, %252f,
+ // %2%66), but ones it deliberately does NOT: an overlong-UTF-8 %c0%ae or %e0%80%ae, or an IIS-style
+ // %u002e, which a permissive server decodes to '/' or '.' yet a single-byte decode leaves as high bytes
+ // or literal text - so they would sail past the segment scan in isEndpointUnderIssuerPath and, sitting
+ // past the issuer prefix, slip the scope. A literal backslash likewise folds to '/' on some proxies.
+ // Failing closed on any '%' or '\' keeps the issuer-path scope airtight against every encoding trick; a
+ // provider that genuinely percent-encodes its endpoint path must be configured explicitly with
+ // OidcDeviceAuth.builder(), which pins the origin only.
+ for (int i = 0, n = rawEndpointPath.length(); i < n; i++) {
+ char c = rawEndpointPath.charAt(i);
+ if (c == '%' || c == '\\') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError, String statusError) {
+ HttpClient client = endpoint.isTls
+ ? HttpClientFactory.newTlsInstance(DISCOVERY_HTTP_CONFIG, tlsConfig)
+ : HttpClientFactory.newPlainTextInstance(DISCOVERY_HTTP_CONFIG);
+ // allocate the native lexer inside the try: new JsonLexer mallocs and can throw (native OOM), and
+ // the client is already allocated, so a throw before the try is entered would skip the finally and
+ // leak the client's native buffers
+ JsonLexer lexer = null;
+ try {
+ lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES);
+ HttpClient.Request request = client.newRequest(endpoint.host, endpoint.port)
+ .GET()
+ .url(path)
+ .header("Accept", "application/json")
+ .header("User-Agent", USER_AGENT);
+ HttpClient.ResponseHeaders response = request.send(DEFAULT_HTTP_TIMEOUT_MILLIS);
+ response.await(DEFAULT_HTTP_TIMEOUT_MILLIS);
+ Response body = response.getResponse();
+ // A discovery document decides WHERE the user signs in and where the refresh token is POSTed,
+ // so it must be read only out of a response that actually claims to carry one. Parsing
+ // regardless of status let an error page supply that configuration: a 500 from /settings or a
+ // 404 from .well-known whose body happens to hold the right keys - an error envelope, a proxy's
+ // branded page, a captive portal, a tenant-not-found stub - constructed a working instance
+ // pointed wherever those keys said. The token and device-authorization paths already gate on
+ // status; this one did not.
+ requireSuccessStatus(client, response, body, statusError);
+ // parseBody enforces an elapsed-time deadline and a byte cap so an untrusted server cannot wedge
+ // discovery, and its parseLast rejects a truncated document
+ parseBody(body, lexer, parser, DEFAULT_HTTP_TIMEOUT_MILLIS);
+ } catch (HttpClientException | HttpException e) {
+ // HttpException covers a malformed or oversized RESPONSE HEAD rejected by HttpHeaderParser (see
+ // postForm). It is a sibling of HttpClientException, not a subclass, so it escaped both catches
+ // here and left fromQuestDB throwing a type its own javadoc does not name - past every caller's
+ // catch (OidcAuthException) degrade handler. Discovery reaching an unusable response is the same
+ // outcome either way, so report it the same way.
+ throw new OidcAuthException(e).put(reachError);
+ } catch (JsonException e) {
+ throw new OidcAuthException(e).put(parseError);
+ } finally {
+ Misc.free(lexer);
+ Misc.free(client);
+ }
+ }
+
+ private static boolean hasOnlyTokenChars(CharSequence token) {
+ for (int i = 0, n = token.length(); i < n; i++) {
+ char c = token.charAt(i);
+ if (c < 0x20 || c > 0x7e) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static int hexValue(char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + 10;
+ }
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + 10;
+ }
+ return -1;
+ }
+
+ /**
+ * The HTTP transport budgets for a client this class owns, all derived from one timeout.
+ *
+ * DefaultHttpClientConfiguration answers 0 for the connect timeout and 600s for the request timeout, and
+ * HttpClient reads BOTH of those on the connection path: it leaves the TCP connect to the OS when the
+ * connect timeout is 0, and it sizes the TLS handshake as {@code connectTimeout > 0 ? connectTimeout :
+ * defaultTimeout}. Taking the defaults therefore gave the handshake alone a 600s budget -- the whole of
+ * FileTokenStore's DEFAULT_LOCK_STALE_MILLIS -- derived from nothing the caller set, so neither
+ * MAX_HTTP_TIMEOUT_MILLIS nor Builder.build()'s lockStaleMillis floor constrained it.
+ *
+ * That matters beyond a slow request. A refresh runs inside the store's cross-process lock, whose file
+ * is stamped once at creation and never re-stamped, so a hold that outruns the staleness window is
+ * judged abandoned and stolen by a peer. Two holders then POST the same rotating refresh token, and an
+ * identity provider with reuse detection answers by revoking the whole family. Deriving both budgets
+ * from httpTimeoutMillis is what makes the "up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x httpTimeoutMillis"
+ * figure -- which the lock-stale floor, acquireForGetToken's wait cap and getToken()'s javadoc all
+ * quote -- actually true of the code.
+ */
+ private static HttpClientConfiguration httpConfig(final int timeoutMillis) {
+ return new DefaultHttpClientConfiguration() {
+ @Override
+ public int getConnectTimeout() {
+ return timeoutMillis;
+ }
+
+ @Override
+ public int getTimeout() {
+ return timeoutMillis;
+ }
+ };
+ }
+
+ private static boolean isDottedIpv4(String host) {
+ // validate a dotted IPv4 literal (four 0-255 octets) without DNS, so a hostname merely starting
+ // with "127." is not mistaken for the loopback block
+ int octets = 1;
+ int value = 0;
+ int digits = 0;
+ for (int i = 0, n = host.length(); i < n; i++) {
+ char c = host.charAt(i);
+ if (c == '.') {
+ if (digits == 0 || value > 255) {
+ return false;
+ }
+ octets++;
+ value = 0;
+ digits = 0;
+ } else if (c >= '0' && c <= '9') {
+ value = value * 10 + (c - '0');
+ if (++digits > 3) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ return octets == 4 && digits > 0 && value <= 255;
+ }
+
+ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issuer) {
+ // The endpoint's path must be the issuer's path or a sub-path of it, compared segment by segment (so
+ // /realms/prod does not match /realms/production). A root issuer (no path) constrains the origin only.
+ // This stops a tampered /settings from redirecting credentials to a different tenant on a path-based
+ // multi-tenant identity provider (Keycloak issuers are https://host/realms/), which the origin
+ // check alone cannot catch. Mirrors the Python client.
+ String basePath = pathOnly(issuer);
+ int baseEnd = basePath.length();
+ while (baseEnd > 0 && basePath.charAt(baseEnd - 1) == '/') {
+ baseEnd--; // trailing slashes do not add a path segment
+ }
+ if (baseEnd == 0) {
+ return true; // root issuer: origin-only, every path is under it
+ }
+ String[] baseSegs = decodePathSegments(basePath.substring(0, baseEnd));
+ String rawEndpointPath = pathOnly(endpointUrl);
+ // A real OIDC endpoint path never encodes a path separator or uses a backslash; reject either before
+ // the segment comparison, since decodePathSegments resolves them and would split one path segment in
+ // two, letting .../realms/acme%2fevil/token (or its split/backslash forms) slip the issuer-path scope.
+ if (endpointPathHasEncodedSeparator(rawEndpointPath)) {
+ return false;
+ }
+ String[] endpointSegs = decodePathSegments(rawEndpointPath);
+ // a "." or ".." segment is rejected outright: the server normalizes it away, so a naive prefix test
+ // would pass /realms/acme/../evil/token yet it resolves to a different realm
+ for (int i = 0; i < endpointSegs.length; i++) {
+ // strip an RFC 3986 ";matrix" parameter suffix before the dot-segment test: a server or proxy that
+ // drops matrix params resolves "..;" (or "..;x") to "..", so /realms/acme/..;/evil/token would
+ // otherwise slip the issuer-path pin to a sibling realm. decodePathSegments already percent-decoded,
+ // so a "%3b"-encoded ";" is a literal ";" here too.
+ String seg = endpointSegs[i];
+ int semi = seg.indexOf(';');
+ if (semi >= 0) {
+ seg = seg.substring(0, semi);
+ }
+ if (".".equals(seg) || "..".equals(seg)) {
+ return false;
+ }
+ }
+ if (endpointSegs.length < baseSegs.length) {
+ return false;
+ }
+ for (int i = 0; i < baseSegs.length; i++) {
+ if (!baseSegs[i].equals(endpointSegs[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean isLoopbackHost(String host) {
+ // loopback traffic never leaves the host, so a plaintext fetch to it has no network interception
+ // risk; match the whole IPv4 127.0.0.0/8 block and the name "localhost"
+ if (host == null) {
+ return false;
+ }
+ // an address literal needs no resolution: it IS the address, and nobody can point it elsewhere
+ if (host.startsWith("127.") && isDottedIpv4(host)) {
+ return true;
+ }
+ if (!host.equalsIgnoreCase("localhost")) {
+ return false;
+ }
+ // "localhost" is a NAME. RFC 6761 says it must resolve to loopback, and every normal host honours
+ // that - but a minimal image with no /etc/hosts entry leaves it to DNS, and this exemption is
+ // precisely what allows the device code and the refresh token to travel in cleartext. Confirm what
+ // it actually resolves to rather than trusting the spelling, and require EVERY answer to be
+ // loopback: one non-loopback address is enough to send the credential off the machine.
+ try {
+ final InetAddress[] resolved = InetAddress.getAllByName(host);
+ if (resolved.length == 0) {
+ return false;
+ }
+ for (int i = 0; i < resolved.length; i++) {
+ if (!resolved[i].isLoopbackAddress()) {
+ return false;
+ }
+ }
+ return true;
+ } catch (UnknownHostException e) {
+ // fail CLOSED: the caller then requires https, which is never the less safe answer
+ return false;
+ }
+ }
+
+ private static boolean isSameOrigin(Endpoint a, Endpoint b) {
+ // scheme (via isTls), host and port - the security origin; path is deliberately not compared, the
+ // token and device endpoints legitimately differ in path on one authorization server. Endpoint.parse
+ // rejects a non-ASCII host, so this equalsIgnoreCase host compare only ever folds ASCII case - no
+ // non-ASCII homoglyph can fold onto a pinned issuer host here.
+ return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host);
+ }
+
+ private static String originOf(Endpoint endpoint) {
+ return (endpoint.isTls ? "https://" : "http://") + endpoint.host + ':' + endpoint.port;
+ }
+
+ private static void parseBody(Response body, JsonLexer lexer, JsonParser parser, int timeoutMillis) throws JsonException {
+ // read and parse the whole body, bounded by an elapsed-time deadline and a cumulative byte cap, so a
+ // hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming. nanoTime,
+ // not the wall clock: an NTP step or an operator setting the date back must not stretch this bound,
+ // which is the only thing standing between a dribbling identity provider and a wedged caller.
+ final long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
+ long totalBytes = 0;
+ while (true) {
+ final long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ throw new HttpClientException("timed out reading the identity provider response body");
+ }
+ Fragment fragment = body.recv((int) Math.max(1, Math.min(remainingNanos / 1_000_000L, Integer.MAX_VALUE)));
+ if (fragment == null) {
+ break;
+ }
+ totalBytes += fragment.hi() - fragment.lo();
+ if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
+ throw new HttpClientException("the identity provider response body exceeded the size limit");
+ }
+ lexer.parse(fragment.lo(), fragment.hi(), parser);
+ }
+ lexer.parseLast(); // reject a truncated body (unterminated string/object)
+ }
+
+ private static int parseIntOrZero(CharSequence value) {
+ try {
+ return Numbers.parseInt(value);
+ } catch (NumericException e) {
+ return 0;
+ }
+ }
+
+ private static String pathOnly(String url) {
+ // the path component only; Endpoint.parse rejects a url carrying a ?query or #fragment up front, so the
+ // returned path never contains one. A ;matrix parameter, by contrast, stays part of the path, so a
+ // traversal hidden in it (.../token;..%2f..) is still scanned by the issuer-path check.
+ return Endpoint.parse(url).path;
+ }
+
+ private static String percentDecodeOnce(String s) {
+ int pct = s.indexOf('%');
+ if (pct < 0) {
+ return s; // nothing encoded
+ }
+ StringSink sink = new StringSink();
+ sink.put(s, 0, pct);
+ for (int i = pct, n = s.length(); i < n; ) {
+ char c = s.charAt(i);
+ if (c == '%' && i + 2 < n) {
+ int hi = hexValue(s.charAt(i + 1));
+ int lo = hexValue(s.charAt(i + 2));
+ if (hi >= 0 && lo >= 0) {
+ sink.put((char) ((hi << 4) | lo));
+ i += 3;
+ continue;
+ }
+ }
+ sink.put(c);
+ i++;
+ }
+ return sink.toString();
+ }
+
+ private static void putNonNull(StringSink sink, CharSequence tag) {
+ // clear before storing so a repeated key replaces, not concatenates onto, the previous value; a
+ // JSON null arrives from the lexer as the literal "null", so treat it as absent rather than store
+ // the 4-char string "null" as a token, error code, endpoint or user code
+ sink.clear();
+ if (!Chars.equals("null", tag)) {
+ sink.put(tag);
+ }
+ }
+
+ /**
+ * Requires a well-formed 2xx status before a discovery body is trusted as configuration.
+ *
+ * The status is validated to be exactly three bare digits BEFORE any of it is echoed: the header parser
+ * copies the status-line token verbatim apart from SP/CR/LF, so a non-digit byte means a malformed or
+ * hostile status line that must not splice ESC or other control bytes into a message, a log or a
+ * terminal. A short all-digit status ({@code 2}, {@code 5}) is malformed too, and must not be read as a
+ * 2xx class by its leading digit. Mirrors the check {@code readResponse} applies on the token path.
+ *
+ * On rejection the body is drained within the usual bound so the keep-alive connection stays usable; a
+ * body too large or too slow to drain leaves unconsumed bytes, so the connection is dropped instead of
+ * mis-framing the next request's response.
+ */
+ private static void requireSuccessStatus(
+ HttpClient client,
+ HttpClient.ResponseHeaders response,
+ Response body,
+ String statusError
+ ) {
+ DirectUtf8Sequence statusCode = response.getStatusCode();
+ StringSink status = new StringSink();
+ boolean malformed = statusCode == null;
+ if (!malformed) {
+ CharSequence raw = statusCode.asAsciiCharSequence();
+ for (int i = 0, n = raw.length(); i < n; i++) {
+ char c = raw.charAt(i);
+ if (c < '0' || c > '9') {
+ malformed = true;
+ break;
+ }
+ status.put(c);
+ }
+ malformed |= status.length() != 3;
+ }
+ if (malformed) {
+ if (!discardBody(body, DEFAULT_HTTP_TIMEOUT_MILLIS)) {
+ client.disconnect();
+ }
+ throw new OidcAuthException().put(statusError)
+ .put("; the response carried a malformed HTTP status code");
+ }
+ if (status.charAt(0) != '2') {
+ if (!discardBody(body, DEFAULT_HTTP_TIMEOUT_MILLIS)) {
+ client.disconnect();
+ }
+ // the status is proven to be bare digits, so echoing it cannot smuggle control bytes
+ throw new OidcAuthException().put(statusError)
+ .put(" [httpStatus=").put(status).put(']');
+ }
+ }
+
+ private static void requireSecureIdpEndpoint(Endpoint endpoint, String label, String url, boolean allowInsecureTransport) {
+ // https is always fine; plaintext http is allowed only to a loopback host, where the request never
+ // leaves the machine. allowInsecureTransport relaxes the QuestDB link but never the identity
+ // provider: the device code and refresh token must not cross the network in cleartext (matching
+ // the Python client)
+ if (endpoint.isTls || isLoopbackHost(endpoint.host)) {
+ return;
+ }
+ OidcAuthException ex = new OidcAuthException()
+ .put("the ").put(label).put(" uses insecure http, which would send the device code and ")
+ .put("refresh token across the network in cleartext; use an https url");
+ if (allowInsecureTransport) {
+ ex.put(" (allowInsecureTransport relaxes only the QuestDB connection, not the identity provider endpoints)");
+ }
+ throw ex.put(" [url=").put(url).put(']');
+ }
+
+ private static void requireSecureTransport(boolean isTls, String label, String url) {
+ if (!isTls) {
+ throw new OidcAuthException()
+ .put("the ").put(label).put(" uses insecure http, which exposes the OIDC sign-in to network ")
+ .put("attackers; use an https url, or call allowInsecureTransport(true) to override [url=").put(url).put(']');
+ }
+ }
+
+ // package-private, not private: FileTokenStore needs the same treatment for the operator-supplied path
+ // its IO errors embed, and a second copy of this walk in the same package would be the thing to avoid.
+ static String sanitizeForDisplay(String value) {
+ if (value == null) {
+ return null;
+ }
+ final int n = value.length();
+ int firstUnsafe = -1;
+ for (int i = 0; i < n; ) {
+ final int cp = value.codePointAt(i);
+ if (OidcAuthException.isUnsafeForDisplay(cp)) {
+ firstUnsafe = i;
+ break;
+ }
+ i += Character.charCount(cp);
+ }
+ if (firstUnsafe < 0) {
+ return value; // common case: nothing to strip
+ }
+ // an attacker-influenced device-auth field can smuggle in terminal-spoofing characters - ANSI
+ // escapes, CR/LF, or bidi/zero-width formatting (including supplementary-plane "tag" chars that
+ // arrive as surrogate pairs) - that reorder or hide text, so strip them per code point; else a
+ // right-to-left override could make the verification URL a human reads differ from the one their
+ // browser opens
+ StringSink sink = new StringSink();
+ sink.put(value, 0, firstUnsafe);
+ for (int i = firstUnsafe; i < n; ) {
+ final int cp = value.codePointAt(i);
+ final int count = Character.charCount(cp);
+ if (!OidcAuthException.isUnsafeForDisplay(cp)) {
+ sink.put(value, i, i + count);
+ }
+ i += count;
+ }
+ return sink.toString();
+ }
+
+ private static boolean settingsChannelIsPlaintext(Endpoint server) {
+ // /settings over plaintext http to a non-loopback host is MITM-able (only possible with
+ // allowInsecureTransport; the default rejects it), so its advertised endpoints must not be trusted
+ // to route credentials without an out-of-band pin
+ return !server.isTls && !isLoopbackHost(server.host);
+ }
+
+ private static String urlEncode(String value) {
+ try {
+ // the Charset overload is Java 10; the client targets Java 8, so use the String-charset form
+ return URLEncoder.encode(value, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ // UTF-8 is guaranteed present on every JVM, so this is unreachable; rethrow defensively
+ throw new OidcAuthException(e).put("UTF-8 encoding is not supported");
+ }
+ }
+
+ private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint deviceAuthorizationEndpoint, Endpoint issuer) {
+ // the device code and long-lived refresh token are POSTed to the device authorization and token
+ // endpoints. RFC 8628 co-locates them on one authorization server, so reject a config that splits
+ // them across origins (a tampered /settings or discovery document siphoning one off) on every
+ // construction path. The issuer-origin pin here is the explicit builder().issuer() opt-in - a sanity
+ // check that user-supplied endpoints sit on the pinned origin; a provider hosting its endpoints off
+ // the issuer origin must then be configured without an issuer. fromQuestDB pins differently: it
+ // origin-pins only the /settings-advertised endpoints itself (a discovered endpoint is trusted), so
+ // it passes no issuer here and relies on this method only for the co-location check.
+ if (!isSameOrigin(tokenEndpoint, deviceAuthorizationEndpoint)) {
+ throw new OidcAuthException()
+ .put("the OIDC token and device authorization endpoints are on different origins (")
+ .put(originOf(tokenEndpoint)).put(" vs ").put(originOf(deviceAuthorizationEndpoint))
+ .put("); refusing to send credentials. This indicates a misconfigured or tampered OIDC configuration");
+ }
+ if (issuer != null) {
+ if (!isSameOrigin(tokenEndpoint, issuer)) {
+ throw new OidcAuthException()
+ .put("the OIDC token endpoint origin (").put(originOf(tokenEndpoint))
+ .put(") does not match the issuer origin (").put(originOf(issuer))
+ .put("); refusing to send credentials to an endpoint outside the trusted issuer");
+ }
+ if (!isSameOrigin(deviceAuthorizationEndpoint, issuer)) {
+ throw new OidcAuthException()
+ .put("the OIDC device authorization endpoint origin (").put(originOf(deviceAuthorizationEndpoint))
+ .put(") does not match the issuer origin (").put(originOf(issuer))
+ .put("); refusing to send credentials to an endpoint outside the trusted issuer");
+ }
+ }
+ }
+
+ private static void validateTokenChars(CharSequence token, String tokenName) {
+ // The selected token goes verbatim into the "Authorization: Bearer " header sent to the
+ // trusted QuestDB server and into the PG-wire _sso password. A CR/LF or other control char would
+ // break out of the header into the request line (the lexer now decodes a \r or \n escape in the
+ // provider's response into a real control byte), and a non-ASCII char is silently truncated to one
+ // byte by the ASCII header writer. A real OAuth token is printable ASCII, so reject anything else
+ // rather than route a tampered or corrupt credential onto the wire. Token bytes are never embedded
+ // in the message: they are the secret this class protects. (A blank/whitespace-only served token is
+ // handled by storeTokens, which caches it as absent so it is never served, rather than rejected here -
+ // an EMPTY served kind is the legitimate "the grant returned the other kind" case selectToken handles.)
+ if (!hasOnlyTokenChars(token)) {
+ throw new OidcAuthException()
+ .put("the identity provider returned an ").put(tokenName)
+ .put(" containing a disallowed control or non-ASCII character; refusing to use it as a credential");
+ }
+ }
+
+ private static String wellKnownUrl(String issuer) {
+ String trimmed = issuer;
+ while (trimmed.length() > 1 && trimmed.charAt(trimmed.length() - 1) == '/') {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ return trimmed + WELL_KNOWN_OPENID_CONFIGURATION_PATH;
+ }
+
+ private void acquireForGetToken() {
+ throwIfClosed();
+ // Uncontended fast path: a plain CAS. It deliberately bypasses the interruptible timed tryLock in the
+ // loop below, which throws InterruptedException the moment the calling thread merely carries a set
+ // interrupt flag - even on a FREE lock - and then re-arms that flag, so every later getToken() on the
+ // same thread would fail with a valid token sitting in the cache. An ILP producer on a pooled or
+ // managed thread, where interrupt is the standard cancellation signal, is the common case. An
+ // uncontended acquire cannot be behind an interactive sign-in (which holds the lock), so it is correct.
+ if (lock.tryLock()) {
+ return;
+ }
+ // Contended - a peer holds the lock. Never wait behind an interactive signIn(): it holds the lock for
+ // the whole device-code lifetime (up to 30 min) with no token to serve until it completes, so fail fast
+ // and let the caller retry. A peer holding the lock for a quick cached read or a silent refresh
+ // (bounded, usually well under a second) is different - the HttpTokenProvider contract permits a brief
+ // wait behind such a refresh - so poll for the lock in short slices rather than fail every concurrent
+ // caller sharing this instance on each token refresh (the old unconditional tryLock() did exactly that).
+ // Polling, not one blocking acquire, lets us still fail fast the moment an interactive sign-in - or
+ // close() - begins while we wait. Bound the total wait so a stuck or pathologically slow holder degrades
+ // to a retryable failure instead of stalling the flush path without bound - but size the bound to the
+ // holder's OWN worst-case hold, not a single httpTimeoutMillis. A legitimate silent refresh under the
+ // lock runs a send, an await and a body parse, each bounded by httpTimeoutMillis
+ // (LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x in total, the same figure the FileTokenStore lock-stale floor is
+ // derived from), so a peer that waited only one httpTimeoutMillis would fail every concurrent caller
+ // behind a refresh that is going to succeed.
+ // nanoTime, not currentTimeMillis: this bound is an ELAPSED budget on the producer thread, and the
+ // wall clock is adjustable. An NTP step or an operator setting the date back stretches a millis-based
+ // deadline by the size of the jump, so the flush path this exists to protect would stall for however
+ // long the clock moved rather than the documented multiple of httpTimeoutMillis. The body reads
+ // (discardBody, parseBody) and the device-code poll already bound themselves this way. Compare by
+ // DIFFERENCE rather than by ordering, so the arithmetic stays correct across nanoTime's wraparound.
+ final long deadlineNanos = System.nanoTime()
+ + (long) LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE * httpTimeoutMillis * 1_000_000L;
+ while (true) {
+ throwIfClosed();
+ if (interactiveSignInInProgress) {
+ throw new OidcAuthException("an interactive sign-in is in progress on another thread; no token is available without blocking - retry once it completes");
+ }
+ final long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ throw new OidcAuthException("a token refresh is already in progress on another thread and no token became available in time; retry shortly");
+ }
+ try {
+ if (lock.tryLock(Math.min(remainingNanos, GET_TOKEN_LOCK_POLL_SLICE_MILLIS * 1_000_000L), TimeUnit.NANOSECONDS)) {
+ return;
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new OidcAuthException("interrupted while waiting to acquire the OIDC token");
+ }
+ }
+ }
+
+ private boolean adopt(PersistedToken token) {
+ if (token == null) {
+ return false;
+ }
+ // The file is attacker-writable, so the served token - the one getToken() puts verbatim into an
+ // Authorization header or a PG-wire password - is untrusted input. Two failure shapes look similar
+ // here and must be told apart, because the safe answer to each is the opposite of the other.
+ String servedToken = groupsInToken ? token.getIdToken() : token.getAccessToken();
+ String fileRefreshToken = token.getRefreshToken();
+ if (servedToken == null) {
+ // ABSENT, which is a legitimate shape rather than evidence of anything. Under
+ // groupsInToken=false a grant that returned only an id_token has storeTokens null the access
+ // token, and persistIfRotated writes the entry regardless; FileTokenStore also maps an empty
+ // on-disk value to null. Discarding such an entry throws away the refresh token, which is the
+ // one thing persistence exists to preserve, and sends a human back through the device flow
+ // where a single silent refresh would have done - a hard failure for a headless getToken()
+ // consumer. So keep the refresh token and leave the cache empty and expired, which puts
+ // getToken()/signIn() on the refresh path they already have for a null served kind. The refresh
+ // token needs no character check of its own: tryRefresh url-encodes it into the form body
+ // (appendParam -> urlEncode), unlike the served token, which reaches a header verbatim.
+ if (fileRefreshToken == null) {
+ return false; // nothing usable in this entry at all
+ }
+ if (token.getAccessToken() == null && token.getIdToken() == null) {
+ // NEITHER kind present, only a refresh token. That is not the legitimate shape above - it is
+ // positive evidence the entry was not written by this client, so reject the whole thing for
+ // the same reason the tampered-served-token branch below does.
+ //
+ // No grant this client stores can produce it. The device path reaches storeTokens only behind
+ // "accessToken.length() > 0 || idToken.length() > 0", and the refresh path only behind a
+ // non-blank served kind. persistIfRotated() is also reached from adoptRotatedRefreshToken(),
+ // where both kinds CAN be null - the branch below nulls them - so it refuses to write that
+ // shape rather than leave this rejection resting on a callsite count. A file with a refresh
+ // token and nothing else came from somewhere else.
+ //
+ // Left adopted, it is the cheapest credential swap there is: an attacker who can WRITE the
+ // store directory - never needing to read our 0600 file - drops in a file whose fingerprint
+ // fields are all derivable from public config, and the next silent refresh presents THEIR
+ // refresh token. The client then ingests and queries as them, with no prompt, no error, and
+ // nothing in any log recording that the identity changed. Unlike a directory-permission check
+ // this holds on every filesystem, Windows included, where owner-only permissions cannot be
+ // enforced at all.
+ //
+ // The cost when it fires on an honest file is one interactive sign-in. design/oidc-token-
+ // persistence.md states the rule for cross-language writers.
+ return false;
+ }
+ accessToken = null;
+ idToken = null;
+ refreshToken = fileRefreshToken;
+ expiresAtMillis = 0;
+ tokenTtlMillis = 0;
+ lastPersistedRefreshToken = fileRefreshToken;
+ return true;
+ }
+ if (Chars.isBlank(servedToken) || !hasOnlyTokenChars(servedToken) || Chars.equals("null", servedToken)) {
+ // PRESENT but unusable: whitespace-only (which passes hasOnlyTokenChars vacuously, space being
+ // 0x20, yet would be served as a blank "Bearer " header the server only answers with 401),
+ // carrying a control or non-ASCII character, or the four characters "null". Unlike an absent
+ // token this is positive evidence that something else wrote this file, so reject the WHOLE entry
+ // - refresh token included. Adopting the refresh token of a file we know was tampered with would
+ // let an attacker who can write the store swap in their own, and this client would silently sign
+ // in as them.
+ //
+ // On "null" specifically: JsonLexer reports a bare JSON null and a quoted "null" identically, so
+ // design/oidc-token-persistence.md forbids a writer from emitting a bare null at all and requires
+ // an absent value to be OMITTED. A served token that reads as "null" is therefore either a writer
+ // violating that rule - json.dumps({"access_token": None}) is the natural way to get there from
+ // Python, and cross-language sharing is the whole point of freezing the format - or a token
+ // pathological enough to be indistinguishable from one. Neither may become "Bearer null": the
+ // server answers that with 401, and because the persisted expiry is honoured getToken() would go
+ // on serving it rather than refreshing, so the producer 401s with nothing naming the cause until
+ // the clamped expiry lapses. Refusing costs one interactive sign-in, and only to a caller whose
+ // real bearer token is four characters long.
+ //
+ // Checked HERE rather than in FileTokenStore because adopt() is the choke point every TokenStore
+ // goes through, including a caller's own implementation of the SPI. The refresh token needs no
+ // equivalent arm: it is url-encoded into a form body rather than spliced into a header, so a
+ // "null" there is simply rejected by the token endpoint and degrades to an interactive sign-in.
+ return false;
+ }
+ accessToken = token.getAccessToken();
+ idToken = token.getIdToken();
+ // keep the current refresh token when the file carries none, mirroring the REFRESH branch of
+ // storeTokens() -- a stored entry is the same authorization read back, never a new one, so the
+ // grant-specific clearing that a fresh device grant does has no counterpart here. A file with a
+ // valid served token but no refresh_token - a cross-language peer that never received one, or a
+ // tampered file - must not null a live in-memory refresh token: doing so would make a later
+ // tryRefresh() urlEncode(null) and throw an uncaught NPE (aborting the sign-in) instead of refreshing
+ // with the token we still hold or degrading to an interactive sign-in.
+ if (fileRefreshToken != null) {
+ refreshToken = fileRefreshToken;
+ }
+ // the file is attacker-writable (and may have been written under a skewed clock), so bound how long
+ // the loaded token is trusted exactly as storeTokens() bounds a token from the wire: never past
+ // MAX_EXPIRES_IN_SECONDS from now. Clamp the expiry to [0, now + maxLife]: the ceiling stops a tampered
+ // far-future expiry from being trusted for decades, and the floor of 0 keeps a tampered far-past expiry
+ // in the past (1970, well before now) while keeping the validity check (now < expiresAtMillis - skew)
+ // underflow-safe - a near-Long.MIN_VALUE expiry would otherwise wrap that subtraction to a huge
+ // positive and serve a garbage-expiry token as valid forever. An already-expired entry still reads as
+ // expired and falls through to a refresh rather than being served.
+ long maxTokenLifeMillis = MAX_EXPIRES_IN_SECONDS * 1000L;
+ long now = System.currentTimeMillis();
+ expiresAtMillis = Math.max(0L, Math.min(token.getExpiresAtMillis(), now + maxTokenLifeMillis));
+ // Trust the file's stored ISSUED lifetime (bounded to [0, maxLife] against a tampered value), NOT the
+ // remaining span expiresAtMillis - now. effectiveSkewMillis() caps the clock-skew margin at half the
+ // lifetime - a guard meant only for a genuinely short-issued (< 60s) token - so deriving it from the
+ // shrinking remaining span would collapse the 30s skew toward zero as a normal token nears expiry and
+ // let getToken() serve a near-expired token on the flush path instead of refreshing. A tampered ttl can
+ // only shrink the skew (never inflate it past CLOCK_SKEW_MILLIS), exactly as the remaining-span form
+ // could, so trusting the stored value is no less safe; a file that carries no ttl (0) yields the full
+ // skew via effectiveSkewMillis()'s <= 0 branch. storeTokens() stores this same full issued lifetime, so
+ // both paths now give tokenTtlMillis one meaning.
+ tokenTtlMillis = Math.max(0L, Math.min(token.getTokenTtlMillis(), maxTokenLifeMillis));
+ // track what the file actually carried (which is null when it had no refresh_token but we kept a live
+ // one above), so a later non-rotating refresh does not rewrite an unchanged on-disk token, yet a token
+ // we kept that the file did not carry is not mistaken for already-persisted and can be re-saved
+ lastPersistedRefreshToken = fileRefreshToken;
+ return true;
+ }
+
+ /**
+ * Adopts a rotated {@code refresh_token} from a refresh response that did NOT carry the served token
+ * kind, so the rotation is not lost with the rest of the response.
+ *
+ * A refresh response may legally omit the served kind - RFC 6749 6 makes {@code id_token} optional, and
+ * OIDC Core 12.2 says the refresh response is the token response "except that it might not contain an
+ * id_token" - which is exactly the shape a {@code groupsInToken} client meets against a provider that
+ * only mints an id token at authorization time. That response is still a clean 2xx, and the
+ * {@code refresh_token} in it is authoritative: a rotating provider has already invalidated the one we
+ * presented. Keeping the old token would replay a spent credential on every later refresh, which a
+ * reuse-detecting provider answers by revoking the whole token family - so the caller loses the
+ * credential entirely rather than merely failing to refresh it once.
+ *
+ * Only the refresh token is taken. The served kind did not arrive, so the cached tokens and the expiry
+ * stay as they were: the entry reads as expired, {@code tryRefresh()} still reports failure, and the
+ * caller falls back to the interactive flow exactly as before - now holding a refresh token that is
+ * still live, so the NEXT refresh can succeed on its own.
+ */
+ private void adoptRotatedRefreshToken() {
+ if (tokenParser.refreshToken.length() == 0) {
+ return;
+ }
+ refreshToken = tokenParser.refreshToken.toString();
+ // Persist it, for the same reason it is adopted at all. Without this the on-disk entry keeps the
+ // token the provider just burned, so the next process start adopts a dead credential and re-prompts
+ // a human who did not need to be asked. persistIfRotated() writes the snapshot's stale served token
+ // and past expiry alongside it, which adopt() reads back as expired and refreshes - one silent
+ // round trip, against a refresh token that works.
+ persistIfRotated();
+ }
+
+ private void appendEncodedParam(StringSink sink, String name, String encodedValue) {
+ sink.putAscii('&').putAscii(name).putAscii('=').putAscii(encodedValue);
+ }
+
+ private void appendParam(StringSink sink, String name, String value) {
+ sink.putAscii('&').putAscii(name).putAscii('=').putAscii(urlEncode(value));
+ }
+
+ private long effectiveSkewMillis() {
+ // mirror the Python client's TokenSet.is_valid: cap the fixed 30s skew at half the token lifetime, so
+ // a short-lived (< 60s) token is not treated as expired the instant it is issued. With an unknown
+ // lifetime (no token cached yet), fall back to the full skew.
+ if (tokenTtlMillis <= 0) {
+ return CLOCK_SKEW_MILLIS;
+ }
+ return Math.min(CLOCK_SKEW_MILLIS, tokenTtlMillis / 2);
+ }
+
+ private HttpClient httpClient(boolean isTls) {
+ if (isTls) {
+ if (tlsClient == null) {
+ tlsClient = HttpClientFactory.newTlsInstance(clientConfig, tlsConfig);
+ }
+ return tlsClient;
+ }
+ if (plainClient == null) {
+ plainClient = HttpClientFactory.newPlainTextInstance(clientConfig);
+ }
+ return plainClient;
+ }
+
+ private boolean isHttpStatusSuccess() {
+ // responseStatus is the bare-digit HTTP status captured by readResponse. A real status is exactly 3
+ // digits, so require that before reading the leading digit: a malformed short status such as "2" must
+ // not be mistaken for a 2xx success and accepted as a grant.
+ return responseStatus.length() == 3 && responseStatus.charAt(0) == '2';
+ }
+
+ private boolean isHttpStatusTerminal4xx() {
+ // a 4xx other than 429 is a terminal client-error rejection (429 is a transient rate-limit); require a
+ // full 3-digit status so a malformed short "4" is not classified as a terminal 4xx
+ return responseStatus.length() == 3 && responseStatus.charAt(0) == '4' && !Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus);
+ }
+
+ private boolean isHttpStatusTransient() {
+ // a 5xx server error or a 429 rate-limit is transient - keep polling; any other non-2xx (a 4xx
+ // rejection) is terminal. Mirrors the Python client's _http_status_is_transient. Require a full
+ // 3-digit status so a malformed short "5" is not classified as a transient 5xx.
+ return responseStatus.length() == 3 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus));
+ }
+
+ private boolean isRefreshBackedOff() {
+ if (refreshFailedAtMillis == 0) {
+ return false;
+ }
+ // elapsed == 0 is the COMMON case, not an edge one: a producer retrying rows calls getToken() many
+ // times within the same millisecond, and that is exactly the flood this exists to stop - so zero
+ // counts as backed off. Only a NEGATIVE span, which means the clock jumped backwards, releases the
+ // latch early rather than pinning it until the clock catches up.
+ final long elapsed = System.currentTimeMillis() - refreshFailedAtMillis;
+ return elapsed >= 0 && elapsed < MIN_REFRESH_RETRY_INTERVAL_MILLIS;
+ }
+
+ private boolean isStoreLoadBackedOff() {
+ final long remaining = nextStoreLoadAttemptMillis - System.currentTimeMillis();
+ // Unlike isRefreshBackedOff(), a zero remaining span does NOT count as backed off: the first failure
+ // arms a zero-length back-off on purpose, so a same-millisecond retry still re-reads the store.
+ // A span longer than the cap cannot have been armed here, so it means the clock jumped BACKWARDS -
+ // release the latch rather than pin the store unreadable until the clock catches up.
+ return remaining > 0 && remaining <= MAX_STORE_LOAD_RETRY_INTERVAL_MILLIS;
+ }
+
+ private void maybeLoadFromStore() {
+ if (tokenStore == null || storeLoadAttempted || isStoreLoadBackedOff()) {
+ return;
+ }
+ PersistedToken token;
+ try {
+ token = tokenStore.load(storeKey);
+ } catch (RuntimeException e) {
+ // Best-effort: a store read failure must not break sign-in. Leave storeLoadAttempted UNSET so a
+ // transient failure is retried on a later call. Latching it here instead would make one failed
+ // read disable persistence for the whole life of this instance - so a process that owns a
+ // perfectly good refresh token on disk would re-run the interactive device flow, which for a
+ // headless getToken() consumer is a hard failure rather than a degraded one.
+ //
+ // Retried, but not on EVERY call: this runs on the getToken() path ahead of the cache check, so
+ // without a back-off a store that never becomes readable costs a blocking file open, two stack
+ // trace fills and a WARN line per ILP flush, forever, on the producer thread and under the lock.
+ // The first failure arms a zero-length back-off (an immediate retry, for the one-shot faults
+ // above), then each consecutive failure doubles it up to MAX_STORE_LOAD_RETRY_INTERVAL_MILLIS.
+ final long backOffMillis = storeLoadRetryIntervalMillis;
+ storeLoadRetryIntervalMillis = backOffMillis == 0
+ ? MIN_STORE_LOAD_RETRY_INTERVAL_MILLIS
+ : Math.min(backOffMillis * 2, MAX_STORE_LOAD_RETRY_INTERVAL_MILLIS);
+ nextStoreLoadAttemptMillis = System.currentTimeMillis() + backOffMillis;
+ warnPersistence("load", e);
+ return;
+ }
+ // the read COMPLETED, so its answer is definitive: a missing, corrupt or foreign-identity file yields
+ // null without throwing, and re-reading it on every later call would buy nothing
+ storeLoadAttempted = true;
+ adopt(token);
+ }
+
+ private void persistIfRotated() {
+ if (tokenStore == null) {
+ return;
+ }
+ // This instance has now produced tokens of its own, so the on-disk entry is no longer authoritative
+ // for it and must never be read back over them. maybeLoadFromStore() deliberately leaves the latch
+ // UNSET when a read THROWS, so a transient fault is retried - but it runs at the top of getToken(),
+ // ahead of the cache check, and adopt() assigns the served kind, the expiry and the ttl
+ // unconditionally. Without this line: a store directory that is unavailable during signIn() (an
+ // unmounted home, a container started before its volume attaches) fails the read, the device flow
+ // completes, the save fails the same way and is swallowed, and then the directory recovers - so the
+ // next getToken(), one per ILP flush, re-reads and installs the PREVIOUS entry over the grant a
+ // human just authorized. Latched here rather than in storeTokens() so the refresh-only path through
+ // adoptRotatedRefreshToken() is covered by the same line, and before the rotation check below
+ // because it is true whether or not this call writes anything.
+ storeLoadAttempted = true;
+ // Never write an entry adopt() will refuse. It rejects a refresh token carried with NEITHER token
+ // kind as positive evidence of a foreign writer, and that reasoning is only sound while this client
+ // cannot produce the shape. It can: adopt()'s own served-kind-absent branch nulls BOTH kinds while
+ // keeping the refresh token, so a later refresh that rotates the refresh token but still returns no
+ // served kind reaches adoptRotatedRefreshToken() -> here with both null. Writing it would leave a
+ // file this client rejects for the life of the entry - a headless getToken() consumer re-running
+ // the device flow on every restart over a refresh token sitting on disk.
+ //
+ // Skipping the write leaves the previous entry in place, which is the better of the two: its
+ // refresh token is the one the provider just burned, so the next start spends one silent round
+ // trip and falls back to an interactive sign-in - the same end state, without a file that can
+ // never be read back.
+ if (accessToken == null && idToken == null) {
+ return;
+ }
+ // persist on a new or rotated refresh token (the interactive sign-in, or a provider that rotates the
+ // refresh token on every refresh); skip when it is unchanged, so the hot getToken() refresh path does
+ // not rewrite the file every few minutes. The on-disk access token then goes stale, which costs only
+ // one silent refresh on the next restart. With no refresh token there is nothing worth persisting.
+ if (Objects.equals(refreshToken, lastPersistedRefreshToken)) {
+ return;
+ }
+ try {
+ tokenStore.save(storeKey, snapshot());
+ lastPersistedRefreshToken = refreshToken;
+ } catch (RuntimeException e) {
+ // best-effort: a save failure never fails an otherwise-valid sign-in; the token is valid in memory
+ warnPersistence("save", e);
+ }
+ }
+
+ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) {
+ // url-encode the opaque device code once here, not on every poll: it is invariant for the whole
+ // poll loop (the grant_type and client_id are likewise pre-encoded)
+ final String deviceCodeEncoded = urlEncode(deviceCode);
+ final long deadlineNanos = System.nanoTime() + expiresInSeconds * 1_000_000_000L;
+ long intervalMillis = (long) intervalSeconds * 1000L;
+ while (true) {
+ throwIfClosed();
+ // check the deadline before polling so an expiry that elapsed during the previous sleep aborts
+ // here, not after one more wasted poll round-trip
+ if (System.nanoTime() >= deadlineNanos) {
+ throw new OidcAuthException("timed out waiting for authorization, the device code expired; please retry");
+ }
+ try {
+ int result = pollOnce(deviceCodeEncoded);
+ if (result == POLL_SUCCESS) {
+ return;
+ }
+ if (result == POLL_SLOW_DOWN) {
+ // grow the interval per RFC 8628, capped at the same bound as the initial value so
+ // repeated slow_down / 429 responses cannot inflate the wait without bound
+ intervalMillis = Math.min(intervalMillis + SLOW_DOWN_INCREMENT_SECONDS * 1000L, MAX_POLL_INTERVAL_SECONDS * 1000L);
+ }
+ // POLL_PENDING and POLL_TRANSIENT_ERROR (a transient 5xx) just poll again
+ } catch (HttpClientException e) {
+ // a transport failure (dropped connection, DNS blip, timeout) is transient: the user may
+ // already have authorized, and RFC 8628 expects polling to continue until the device code
+ // expires, so poll again rather than discard the sign-in (the deadline bounds the total
+ // wait). Matches the Python client.
+ } catch (OidcAuthException e) {
+ // a garbled / non-JSON body (a JsonException cause) is transient too, UNLESS its HTTP status
+ // is a terminal rejection (a non-JSON 4xx from a WAF or proxy); a well-formed terminal answer
+ // - an OAuth error, a terminal 4xx, a malformed status line - always aborts
+ if (!(e.getCause() instanceof JsonException) || isHttpStatusTerminal4xx()) {
+ throw e;
+ }
+ }
+ // wait for the next poll, never past the device-code deadline, so the timeout check at the top
+ // of the loop fires promptly at expiry instead of up to one poll interval late
+ sleepBetweenPolls(Math.min(intervalMillis, (deadlineNanos - System.nanoTime()) / 1_000_000L));
+ }
+ }
+
+ private int pollOnce(String deviceCodeEncoded) {
+ formSink.clear();
+ formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_DEVICE_CODE_ENCODED);
+ appendEncodedParam(formSink, "device_code", deviceCodeEncoded);
+ appendEncodedParam(formSink, "client_id", clientIdEncoded);
+
+ tokenParser.clear();
+ // a transport failure here propagates to pollForToken, which keeps polling (a transient blip) until
+ // the device-code deadline rather than swallowing it as a pending authorization
+ postForm(tokenEndpoint, tokenParser);
+
+ // RFC 6749 5.2: an error response is an error even if the body also carries a token, or the status is
+ // 429 - so handle the OAuth error first. A terminal error (e.g. access_denied) must abort even when
+ // the identity provider also rate-limits, and a token smuggled alongside an error must never count as
+ // a grant.
+ if (tokenParser.error.length() > 0) {
+ if (Chars.equals(ERROR_AUTHORIZATION_PENDING, tokenParser.error)) {
+ return POLL_PENDING;
+ }
+ if (Chars.equals(ERROR_SLOW_DOWN, tokenParser.error)) {
+ return POLL_SLOW_DOWN;
+ }
+ throw OidcAuthException.oauthError(tokenParser.error, tokenParser.errorDescription);
+ }
+
+ // A rate-limited identity provider answers 429 with no OAuth error; RFC 8628 does not define it, but
+ // the Python client and common practice treat it as "poll slower". Back off and keep polling (like
+ // slow_down) rather than treating it as a terminal error, so transient rate limiting does not fail
+ // the sign-in.
+ if (Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus)) {
+ return POLL_SLOW_DOWN;
+ }
+ // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx is malformed and
+ // is not trusted (the non-2xx is classified below instead)
+ if (isHttpStatusSuccess()) {
+ if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) {
+ storeTokens(tokenParser, false);
+ return POLL_SUCCESS;
+ }
+ // a 2xx with neither a token nor an OAuth error is a definitive but malformed answer
+ throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']');
+ }
+ // a non-2xx with no recognized OAuth error: a 5xx (or 429, handled above) is a transient server or
+ // gateway condition - keep polling to the deadline; any other status is a terminal rejection (a 4xx
+ // from the identity provider, a WAF or a proxy) that aborts immediately rather than polling on to a
+ // misleading "device code expired". Matches the Python client.
+ if (isHttpStatusTransient()) {
+ return POLL_TRANSIENT_ERROR;
+ }
+ throw new OidcAuthException().put("the token endpoint rejected the request [httpStatus=").put(responseStatus).put("]; refusing to keep polling");
+ }
+
+ private void postForm(Endpoint endpoint, JsonParser parser) {
+ HttpClient client = httpClient(endpoint.isTls);
+ HttpClient.Request request = client.newRequest(endpoint.host, endpoint.port)
+ .POST()
+ .url(endpoint.path)
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .header("Accept", "application/json")
+ .header("User-Agent", USER_AGENT);
+ request.withContent();
+ request.putAscii(formSink);
+ try {
+ HttpClient.ResponseHeaders response = request.send(httpTimeoutMillis);
+ response.await(httpTimeoutMillis);
+ readResponse(client, response, parser);
+ } catch (HttpClientException e) {
+ // a transport failure, or a bounded-read abort in parseBody (its elapsed-time deadline or the
+ // MAX_RESPONSE_BODY_BYTES cap), leaves the response half-read with unconsumed bytes in this
+ // cached keep-alive connection. Drop it so the next poll or refresh reconnects with a clean
+ // socket instead of parsing the previous response's leftovers - which pollForToken would
+ // otherwise keep doing, on a corrupted connection, until the device code expires. Mirrors the
+ // disconnect-on-failure handling in AbstractLineHttpSender.flush0.
+ client.disconnect();
+ throw e;
+ } catch (HttpException e) {
+ // The RESPONSE HEAD was malformed or oversized, so HttpHeaderParser rejected it: a header block
+ // past the fixed 4096-byte parse buffer (a WAF or proxy stacking Set-Cookie/CSP), a malformed
+ // Content-Length, or a status line that is not HTTP/1.x. HttpException is a SIBLING of
+ // HttpClientException, not a subclass, so it missed the catch above - and with it the disconnect,
+ // leaving this CACHED keep-alive connection holding a half-read response for the next poll to
+ // parse as its own, exactly the corruption that catch exists to prevent. It also missed every
+ // classification downstream, aborting an interactive sign-in outright on a condition the same
+ // code rides out when it arrives as a transport error, and surfacing a type fromQuestDB/signIn
+ // do not document. The identity provider is untrusted here, so this is a response shape it can
+ // choose at will.
+ //
+ // Both are "the response is unusable", so answer identically: drop the connection and re-report
+ // as the transport-class failure every caller already handles. The message is a parser constant,
+ // never response bytes, so it carries no untrusted text. Copying it out also detaches the
+ // thread-local flyweight HttpException.instance() hands back, whose message the next
+ // HttpException on this thread would overwrite.
+ client.disconnect();
+ throw new HttpClientException("malformed response from the identity provider: " + e.getMessage());
+ }
+ }
+
+ private void readResponse(HttpClient client, HttpClient.ResponseHeaders response, JsonParser parser) {
+ // capture only the HTTP status for diagnostics; the body is never retained or surfaced in a
+ // message - it carries access, id and refresh tokens that must not reach logs or exceptions
+ responseStatus.clear();
+ DirectUtf8Sequence statusCode = response.getStatusCode();
+ Response body = response.getResponse();
+ if (statusCode != null) {
+ // a well-formed HTTP status code is bare digits, but the header parser copies the status-line
+ // token verbatim apart from SP/CR/LF, so a non-digit byte means a malformed or hostile status
+ // line. Reject it rather than echo any byte (which could smuggle ESC or other control sequences
+ // into a log or terminal when responseStatus is surfaced in a message below) or trust its
+ // leading digit as a success gate. Drain the body first to keep the keep-alive connection usable;
+ // if it could not be fully drained, drop the connection so the next request does not read this
+ // body's leftovers.
+ CharSequence raw = statusCode.asAsciiCharSequence();
+ for (int i = 0, n = raw.length(); i < n; i++) {
+ char c = raw.charAt(i);
+ if (c < '0' || c > '9') {
+ if (!discardBody(body, httpTimeoutMillis)) {
+ client.disconnect();
+ }
+ throw new OidcAuthException("the identity provider returned a malformed HTTP status code");
+ }
+ responseStatus.put(c);
+ }
+ }
+ jsonLexer.clear();
+ try {
+ parseBody(body, jsonLexer, parser, httpTimeoutMillis);
+ } catch (JsonException e) {
+ // drain the rest to keep the keep-alive connection usable; never embed the body, it may carry
+ // tokens. A body too large to drain within the cap (e.g. a multi-MB malformed response) leaves
+ // unconsumed bytes, so drop the connection rather than mis-frame the next request's response.
+ if (!discardBody(body, httpTimeoutMillis)) {
+ client.disconnect();
+ }
+ throw new OidcAuthException(e)
+ .put("could not parse the identity provider response [httpStatus=").put(responseStatus).put(']');
+ }
+ }
+
+ private boolean refreshUnderLock() {
+ // runs inside the store's cross-process lock: re-read first, since another process sharing this
+ // identity may have refreshed (and rotated the refresh token) since our last load. Adopt a fresher
+ // entry and skip the network when it already yields a valid token; otherwise refresh with the freshest
+ // known refresh token (the one just adopted, so a rotated token is not replayed).
+ //
+ // Only re-read when the in-memory refresh token still matches what we last persisted. A mismatch no
+ // longer strictly means "in-memory is a newer unsaved token": it covers two cases, and re-adopting
+ // would regress in both, so keep the in-memory token and refresh with it. (1) A previous save failed
+ // (persistence is best-effort), so the in-memory token is genuinely newer than the on-disk one;
+ // re-adopting would regress it to the stale - and, on a rotating identity provider, already-revoked -
+ // on-disk token and force a needless re-prompt. (2) adopt() kept a live in-memory token that the loaded
+ // file did not carry (a cross-language peer that never received a refresh_token), leaving
+ // lastPersistedRefreshToken null; here the trade-off is that if a rotating-IdP peer has since revoked
+ // our token and written a fresher one, we skip that fresher on-disk token this round and fall back to an
+ // interactive re-prompt. Both are benign (never a stale/wrong served token; the pre-fix alternative in
+ // case 2 was an uncaught urlEncode(null) NPE) and cross-process-only.
+ if (Objects.equals(refreshToken, lastPersistedRefreshToken)) {
+ PersistedToken fresh;
+ try {
+ fresh = tokenStore.load(storeKey);
+ } catch (RuntimeException e) {
+ warnPersistence("load", e);
+ fresh = null;
+ }
+ if (adopt(fresh)) {
+ final String servedToken = groupsInToken ? idToken : accessToken;
+ if (servedToken != null && System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
+ return true;
+ }
+ }
+ }
+ return tryRefresh();
+ }
+
+ private void runDeviceFlow() {
+ formSink.clear();
+ formSink.putAscii("client_id=").putAscii(clientIdEncoded);
+ appendEncodedParam(formSink, "scope", scopeEncoded);
+ if (audienceEncoded != null) {
+ appendEncodedParam(formSink, "audience", audienceEncoded);
+ }
+
+ deviceAuthParser.clear();
+ try {
+ postForm(deviceAuthorizationEndpoint, deviceAuthParser);
+ } catch (HttpClientException e) {
+ throw new OidcAuthException(e).put("could not reach the device authorization endpoint");
+ }
+
+ if (deviceAuthParser.error.length() > 0) {
+ throw OidcAuthException.oauthError(deviceAuthParser.error, deviceAuthParser.errorDescription);
+ }
+ // RFC 8628 3.2: a device authorization grant is a 2xx response. A non-2xx body with no OAuth error
+ // (handled above) is malformed or hostile; reject it rather than prompt the user and poll on it -
+ // the same 2xx gate pollOnce and tryRefresh apply before trusting a token
+ if (!isHttpStatusSuccess()) {
+ throw new OidcAuthException().put("unexpected response from the device authorization endpoint [httpStatus=").put(responseStatus).put(']');
+ }
+ // the device code is sent in the poll requests, not shown, so check it on the wire; the user code and
+ // verification URL are shown to the user, so sanitize them first and require them non-empty after
+ // sanitizing - a value made entirely of control/format chars is non-empty on the wire but would
+ // otherwise display as a blank code or URL
+ final String deviceCode = deviceAuthParser.deviceCode.toString();
+ final String userCode = sanitizeForDisplay(deviceAuthParser.userCode.toString());
+ final String verificationUri = sanitizeForDisplay(deviceAuthParser.verificationUri.toString());
+ if (deviceCode.isEmpty() || userCode.isEmpty() || verificationUri.isEmpty()) {
+ throw new OidcAuthException().put("incomplete device authorization response from the identity provider [httpStatus=").put(responseStatus).put(']');
+ }
+ // a verification_uri_complete that is non-empty on the wire but sanitizes to empty is treated as
+ // absent (null), so the prompt prints no blank "(or open this URL ...)" line and the browser launcher
+ // is never handed an empty string
+ String verificationUriComplete = deviceAuthParser.verificationUriComplete.length() > 0
+ ? sanitizeForDisplay(deviceAuthParser.verificationUriComplete.toString())
+ : null;
+ if (verificationUriComplete != null && verificationUriComplete.isEmpty()) {
+ verificationUriComplete = null;
+ }
+
+ final int expiresInSeconds = boundedSeconds(deviceAuthParser.expiresIn, DEFAULT_DEVICE_CODE_TTL_SECONDS, MAX_DEVICE_CODE_TTL_SECONDS);
+ final int intervalSeconds = boundedSeconds(deviceAuthParser.interval, DEFAULT_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS);
+ final DeviceAuthorizationChallenge challenge = new DeviceAuthorizationChallenge(
+ userCode,
+ verificationUri,
+ verificationUriComplete,
+ expiresInSeconds,
+ intervalSeconds
+ );
+
+ throwIfClosed();
+ prompt.promptUser(challenge);
+ pollForToken(deviceCode, expiresInSeconds, intervalSeconds);
+ }
+
+ private String selectToken() {
+ if (groupsInToken) {
+ if (idToken != null) {
+ return idToken;
+ }
+ throw new OidcAuthException()
+ .put("the server expects groups encoded in the token (acl.oidc.groups.encoded.in.token=true) but the ")
+ .put("identity provider returned no id_token; ensure the requested scope includes 'openid'");
+ }
+ if (accessToken != null) {
+ return accessToken;
+ }
+ throw new OidcAuthException("the identity provider returned no access_token");
+ }
+
+ private void sleepBetweenPolls(long millis) {
+ // Sleep in short slices so close() can abort an in-flight sign-in within ~POLL_SLEEP_SLICE_MILLIS
+ // instead of after a full (possibly slow_down-inflated) interval.
+ //
+ // Thread.sleep, not Os.sleep: Os.sleep catches InterruptedException, recomputes its deadline and
+ // keeps sleeping, and Thread.sleep CLEARS the flag when it throws - so the caller's interrupt was
+ // not merely ignored here, it was destroyed. A caller who cancelled then returned from signIn() with
+ // Thread.interrupted() reading false, its own cancellation bookkeeping none the wiser, having waited
+ // out a poll loop that runs to the device-code lifetime. This class states the opposite invariant
+ // twice ("the flag is the caller's cancellation signal and must survive this call"), and getToken()
+ // and FileTokenStore.load()/save() honour it.
+ long remaining = millis;
+ while (remaining > 0) {
+ throwIfClosed();
+ throwIfInterrupted("the calling thread was interrupted while waiting for authorization");
+ long slice = Math.min(POLL_SLEEP_SLICE_MILLIS, remaining);
+ try {
+ Thread.sleep(slice);
+ } catch (InterruptedException e) {
+ // Thread.sleep cleared the flag; put it back and let the caller see the cancellation both
+ // ways - as the exception below and as the flag their own shutdown path is waiting on.
+ Thread.currentThread().interrupt();
+ throwIfInterrupted("the calling thread was interrupted while waiting for authorization");
+ }
+ remaining -= slice;
+ }
+ }
+
+ private PersistedToken snapshot() {
+ return new PersistedToken(accessToken, idToken, refreshToken, expiresAtMillis, tokenTtlMillis);
+ }
+
+ /**
+ * @param isRefreshGrant true for a refresh_token grant, false for a fresh device grant. Decides what an
+ * OMITTED refresh_token means, which is not the same question for the two grants.
+ */
+ private void storeTokens(TokenResponseParser parser, boolean isRefreshGrant) {
+ // reject a token with control or non-ASCII chars before caching: getToken() serves it verbatim as an
+ // HTTP Authorization header value and a PG-wire password, where a decoded CR/LF would inject into the
+ // request line sent to the trusted QuestDB server. Validate only the kind getToken() actually serves
+ // (the one that reaches the wire); the other kind is cached but never sent, so a stray char in it must
+ // not abort an otherwise-usable grant.
+ if (groupsInToken) {
+ validateTokenChars(parser.idToken, "id_token");
+ } else {
+ validateTokenChars(parser.accessToken, "access_token");
+ }
+ // treat a blank (empty OR whitespace-only) token as absent (null), not as a usable credential: a
+ // whitespace-only served token passes the char check vacuously (space is 0x20) but would be served as
+ // a blank "Bearer " header the server only answers with 401, so cache it as null and let selectToken /
+ // the wrong-token-kind fallback handle a missing served kind rather than serve it. An empty string was
+ // already treated as absent here; this only additionally folds in whitespace-only, matching adopt() and
+ // the sender's own HttpTokenProvider.validateToken (Chars.isBlank).
+ accessToken = Chars.isBlank(parser.accessToken) ? null : parser.accessToken.toString();
+ idToken = Chars.isBlank(parser.idToken) ? null : parser.idToken.toString();
+ // What an omitted refresh_token means depends on the grant, so the two must not share a policy.
+ // A refresh response usually omits one (RFC 6749 6 makes it optional) and is the SAME authorization
+ // continuing, so the current token stays valid and is kept -- dropping it would send a human back
+ // through the device flow every time a non-rotating provider answers.
+ // A device grant is a NEW authorization and may be a DIFFERENT human. Keeping the previous user's
+ // refresh token across it is cross-account confusion: user A's refresh fails, user B completes the
+ // device flow without a refresh token, B's access token expires, and the next silent refresh presents
+ // A's retained token and resumes as A -- no prompt, no error, nothing in any log to say the identity
+ // changed. So an omission here clears it: this authorization has no refresh token, and the honest
+ // outcome is that getToken() asks for an interactive sign-in.
+ if (parser.refreshToken.length() > 0) {
+ refreshToken = parser.refreshToken.toString();
+ } else if (!isRefreshGrant) {
+ refreshToken = null;
+ }
+ // clamp like the device-side expires_in: default for a non-positive value, cap an absurd one, so a
+ // hostile or buggy token TTL cannot cache the token for decades (the server still enforces the real
+ // expiry; this only bounds how long the client trusts its cached copy)
+ int ttlSeconds = boundedSeconds(parser.expiresIn, DEFAULT_TOKEN_TTL_SECONDS, MAX_EXPIRES_IN_SECONDS);
+ tokenTtlMillis = ttlSeconds * 1000L;
+ expiresAtMillis = System.currentTimeMillis() + tokenTtlMillis;
+ persistIfRotated();
+ }
+
+ private void throwIfClosed() {
+ if (closed) {
+ throw new OidcAuthException("the OidcDeviceAuth instance is closed");
+ }
+ }
+
+ /**
+ * Abandons the current step when the calling thread carries an interrupt, LEAVING THE FLAG SET.
+ *
+ * isInterrupted(), never interrupted(): the flag is the caller's cancellation signal and has to outlive
+ * this call, so the shutdown path that raised it - an ExecutorService.shutdownNow(), a Future.cancel,
+ * QWP's ConnectCancellation - still sees it. Clearing it here would leave the caller believing it was
+ * never cancelled, which is the failure this guard exists to stop rather than one more of its causes.
+ *
+ * @param message what the caller was doing when the cancellation was noticed
+ */
+ private void throwIfInterrupted(String message) {
+ if (Thread.currentThread().isInterrupted()) {
+ throw new OidcAuthException(message);
+ }
+ }
+
+ private boolean tryRefresh() {
+ if (refreshToken == null) {
+ // nothing to present: degrade to the interactive flow rather than urlEncode(null) and throw.
+ // adopt() keeps a live refresh token, so this only fires if a caller reaches here with none.
+ return false;
+ }
+ formSink.clear();
+ formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED);
+ appendParam(formSink, "refresh_token", refreshToken);
+ appendEncodedParam(formSink, "client_id", clientIdEncoded);
+ appendEncodedParam(formSink, "scope", scopeEncoded);
+ if (audienceEncoded != null) {
+ appendEncodedParam(formSink, "audience", audienceEncoded);
+ }
+
+ tokenParser.clear();
+ try {
+ postForm(tokenEndpoint, tokenParser);
+ } catch (HttpClientException e) {
+ // could not reach the token endpoint; fall back to the interactive flow
+ return false;
+ } catch (OidcAuthException e) {
+ // postForm throws OidcAuthException only on a parse failure (a garbled / unparseable refresh
+ // response), never an OAuth error: a genuine OAuth error arrives in tokenParser.error, handled
+ // by hasRequiredToken below. So treat this as a transient blip and fall back to the interactive
+ // flow rather than fail the whole getToken() call
+ return false;
+ }
+ // succeed only on a clean 2xx (no OAuth error) returning the token getToken() actually serves (the
+ // id token when groups are encoded in it, the access token otherwise). A refresh that omits the served
+ // kind - which RFC 6749 permits and many providers do - or returns it blank/whitespace-only, or carries
+ // an error or a non-2xx status, must fall back to the interactive flow rather than be cached. Test the
+ // served kind with Chars.isBlank, the SAME contract storeTokens/adopt use to fold a blank token to null:
+ // gating on length() > 0 here would pass a whitespace-only token, which storeTokens then nulls, so
+ // tryRefresh would report success while selectToken() throws "no token" instead of falling back.
+ //
+ // Split the "this response is a clean grant" half out: it is what decides whether a rotated
+ // refresh_token in the SAME body is authoritative, and that question outlives the served-kind test
+ // below. See adoptRotatedRefreshToken().
+ final boolean isCleanGrant = isHttpStatusSuccess() && tokenParser.error.length() == 0;
+ boolean hasRequiredToken = (groupsInToken
+ ? !Chars.isBlank(tokenParser.idToken)
+ : !Chars.isBlank(tokenParser.accessToken))
+ && isCleanGrant;
+ if (hasRequiredToken) {
+ try {
+ storeTokens(tokenParser, true);
+ } catch (OidcAuthException e) {
+ // storeTokens -> validateTokenChars rejects a refreshed served token carrying a control or
+ // non-ASCII char (reachable now that JsonLexer decodes an escaped \r/\n in the response into a
+ // real byte). Fall back to the interactive flow like the transport/parse-failure arms above,
+ // rather than let the rejection propagate out of getToken()/signIn() past the runDeviceFlow()
+ // fallback the caller expects. validateTokenChars runs before any state mutation, so the cached
+ // token and refresh token are left intact for that fallback.
+ return false;
+ }
+ return true;
+ }
+ if (isCleanGrant) {
+ // The provider accepted our refresh token and answered 2xx; it simply did not return the kind
+ // getToken() serves. Take the rotated refresh_token before dropping the rest of the response -
+ // see adoptRotatedRefreshToken() for why keeping the old one is worse than failing this refresh.
+ adoptRotatedRefreshToken();
+ }
+ // the refresh token expired or was revoked, or did not return the token we need; fall back to the
+ // interactive flow
+ return false;
+ }
+
+ private boolean tryRefreshCoordinated() {
+ if (tokenStore == null) {
+ return tryRefresh();
+ }
+ // Serialise the read-refresh-write across processes (and adopt a peer's just-rotated refresh token)
+ // through the store's per-identity lock; a store that does not coordinate just runs the refresh.
+ //
+ // TokenStore is a user-implemented SPI and persistence is documented best-effort, so a store that
+ // throws must not take the sign-in down with it - it did, because inLock was called bare. What the
+ // right degrade is depends entirely on whether the refresh already ran, which only the action
+ // itself can report:
+ // - the store threw BEFORE the action ran: nothing was refreshed, so run ONE uncoordinated
+ // refresh. Exactly one: the point of the lock is that a rotating refresh token must not be
+ // POSTed twice, and a reuse-detecting provider answers a replay by revoking the whole family.
+ // - the store threw AFTER the action completed (releasing a lock, closing a handle): the refresh
+ // HAPPENED and the token is live. Report what the action returned; re-running it would be that
+ // same double-POST, and throwing would tell the caller a completed sign-in failed.
+ // - the action itself threw: that is the refresh's own failure, not the store's. Never swallow
+ // it and never replay it - let it propagate exactly as it did before.
+ // Error is deliberately not caught: an OutOfMemoryError is not a store fault to degrade around.
+ final boolean[] actionEntered = new boolean[1];
+ final boolean[] actionCompleted = new boolean[1];
+ final boolean[] actionResult = new boolean[1];
+ try {
+ return tokenStore.inLock(storeKey, () -> {
+ actionEntered[0] = true;
+ boolean refreshed = refreshUnderLock();
+ actionResult[0] = refreshed;
+ actionCompleted[0] = true;
+ return refreshed;
+ });
+ } catch (RuntimeException e) {
+ if (actionCompleted[0]) {
+ warnPersistence("lock release", e);
+ return actionResult[0];
+ }
+ if (actionEntered[0]) {
+ throw e;
+ }
+ warnPersistence("lock", e);
+ return tryRefresh();
+ }
+ }
+
+ private void wipeCredentialState() {
+ // Best effort, and worth being precise about what that means.
+ //
+ // Nulling the four String fields is all Java offers for a String - the characters live on until the
+ // GC reclaims them - but it does stop this instance from handing them back.
+ //
+ // The sinks are the part a plain null misses. formSink carries the request body, which on the refresh
+ // path is literally "refresh_token="; the two parsers hold every field of the last
+ // response, tokens and device code included. All of them are REUSED, and clear() only rewinds the
+ // write position, so a long secret followed by a short write stays legible in the tail. wipe()
+ // overwrites the whole backing array instead.
+ //
+ // jsonLexer is in that set too, and wiping the parsers alone missed it: the lexer ASSEMBLES every
+ // name and value in its own decode sinks before a listener ever sees one, so the parsers' copies
+ // are the second copy, not the first. It is a field reused for every token response, and neither
+ // JsonLexer.clear() (parse state only) nor close() (frees the native cache without zeroing it)
+ // touches those sinks, so the whole token stayed legible on the heap for the life of this
+ // instance - through clearCache(), which is exactly when a caller expects it gone.
+ //
+ // What it cannot reach: any String already handed to a caller, and the HTTP client's native receive
+ // buffers, where the raw token bytes also passed. Freeing those returns the pages to the allocator
+ // without zeroing them. A caller who needs more than this should not be persisting tokens in this
+ // process at all.
+ accessToken = null;
+ idToken = null;
+ refreshToken = null;
+ lastPersistedRefreshToken = null;
+ formSink.wipe();
+ responseStatus.wipe();
+ deviceAuthParser.wipe();
+ tokenParser.wipe();
+ if (jsonLexer != null) {
+ // null on a second close(): the first one wiped it and then freed the field. close() is
+ // documented idempotent, so the guard keeps it so - there is nothing left to wipe by then
+ // anyway, and the object is already unreachable.
+ jsonLexer.wipe();
+ }
+ }
+
+ private void warnPersistence(String operation, Throwable cause) {
+ // best-effort persistence: warn through SLF4J and carry on with the in-memory token. The store never
+ // puts token bytes in its messages, but an IO error can carry the operator-supplied store path, which
+ // could itself hold terminal-spoofing characters - sanitize the detail before printing, as every other
+ // untrusted display string is sanitized (sanitizeForDisplay is null-safe).
+ String detail = sanitizeForDisplay(cause.getMessage());
+ LOG.warn("OIDC token store {} failed; continuing without persistence{}",
+ operation, detail != null ? " [" + detail + ']' : "");
+ }
+
+ /**
+ * Fluent builder for an {@link OidcDeviceAuth} configured against a known identity provider.
+ * The client id, device authorization endpoint and token endpoint are required.
+ */
+ public static final class Builder {
+ private boolean allowInsecureTransport;
+ private String audience;
+ private String clientId;
+ private String deviceAuthorizationEndpoint;
+ private boolean groupsInToken;
+ private int httpTimeoutMillis = DEFAULT_HTTP_TIMEOUT_MILLIS;
+ private String issuer;
+ private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser();
+ private String scope = DEFAULT_SCOPE;
+ private ClientTlsConfiguration tlsConfig;
+ private String tokenEndpoint;
+ private TokenStore tokenStore;
+
+ private Builder() {
+ }
+
+ /**
+ * Opts into insecure {@code http} for the QuestDB {@code /settings} link (only meaningful via
+ * {@link #fromQuestDB}). It does not relax the identity provider endpoints configured here:
+ * the device authorization and token endpoints always require {@code https} unless they are
+ * loopback, so the device code and refresh token never cross the network in cleartext (matching
+ * the Python client). Defaults to {@code false}.
+ */
+ public Builder allowInsecureTransport(boolean allowInsecureTransport) {
+ this.allowInsecureTransport = allowInsecureTransport;
+ return this;
+ }
+
+ /**
+ * Sets the {@code audience} (or {@code resource}) request parameter, sent on the device
+ * authorization and refresh requests. Some identity providers require it so the issued token
+ * carries the {@code aud} claim QuestDB expects. {@link #fromQuestDB} discovers it from
+ * {@code acl.oidc.audience}. Optional.
+ */
+ public Builder audience(String audience) {
+ this.audience = audience;
+ return this;
+ }
+
+ public OidcDeviceAuth build() {
+ if (clientId == null || clientId.isEmpty()) {
+ throw new OidcAuthException("clientId is required");
+ }
+ if (deviceAuthorizationEndpoint == null || deviceAuthorizationEndpoint.isEmpty()) {
+ throw new OidcAuthException("deviceAuthorizationEndpoint is required");
+ }
+ if (tokenEndpoint == null || tokenEndpoint.isEmpty()) {
+ throw new OidcAuthException("tokenEndpoint is required");
+ }
+ if (scope == null || scope.isEmpty()) {
+ scope = DEFAULT_SCOPE;
+ }
+ Endpoint deviceEndpoint = Endpoint.parse(deviceAuthorizationEndpoint);
+ Endpoint parsedTokenEndpoint = Endpoint.parse(tokenEndpoint);
+ Endpoint issuerEndpoint = issuer != null && !issuer.isEmpty() ? Endpoint.parse(issuer) : null;
+ requireSecureIdpEndpoint(deviceEndpoint, "device authorization endpoint", deviceAuthorizationEndpoint, allowInsecureTransport);
+ requireSecureIdpEndpoint(parsedTokenEndpoint, "token endpoint", tokenEndpoint, allowInsecureTransport);
+ // enforce the credential-endpoint co-location / issuer pin on every construction path, not just
+ // discovery, so the documented guarantee holds for the explicit builder too
+ validateEndpointOrigins(parsedTokenEndpoint, deviceEndpoint, issuerEndpoint);
+ ClientTlsConfiguration tls = tlsConfig != null ? tlsConfig : defaultTlsConfig();
+ // a FileTokenStore steals a lock older than its staleness window, presuming a crashed holder; that
+ // window must exceed the worst-case time a live refresh holds the lock, or a peer could steal a live
+ // holder's lock mid-refresh and reopen the rotating-refresh-token race the lock prevents. Enforce the
+ // bounded part of that worst case here, where both values are known: the refresh I/O under the lock is
+ // up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x httpTimeoutMillis. httpConfig() bounds the connection phase
+ // that precedes the send by httpTimeoutMillis too (the TCP connect and the TLS handshake; DNS
+ // resolution remains the OS's), so this floor covers the hold rather than only part of it. The default
+ // 600s window leaves ample headroom over the floor even at the 120s timeout cap; a caller raising
+ // httpTimeoutMillis should raise lockStaleMillis to keep it. A non-coordinating TokenStore is exempt -
+ // it takes no lock.
+ if (tokenStore instanceof FileTokenStore) {
+ long minStaleMillis = (long) LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE * httpTimeoutMillis;
+ long staleMillis = ((FileTokenStore) tokenStore).getLockStaleMillis();
+ if (staleMillis < minStaleMillis) {
+ throw new OidcAuthException()
+ .put("the FileTokenStore lockStaleMillis (").put(staleMillis)
+ .put(") must be at least ").put(LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE)
+ .put("x httpTimeoutMillis (").put(minStaleMillis)
+ .put("), otherwise a slow refresh's live cross-process lock could be stolen by a peer mid-refresh");
+ }
+ }
+ return new OidcDeviceAuth(this, tls, deviceEndpoint, parsedTokenEndpoint);
+ }
+
+ public Builder clientId(String clientId) {
+ this.clientId = clientId;
+ return this;
+ }
+
+ public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) {
+ this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
+ return this;
+ }
+
+ /**
+ * Selects which token {@link #getToken()} returns. Set to {@code true} when the server has
+ * {@code acl.oidc.groups.encoded.in.token=true} (the id token is returned), {@code false}
+ * otherwise (the access token is returned). Defaults to {@code false}.
+ */
+ public Builder groupsInToken(boolean groupsInToken) {
+ this.groupsInToken = groupsInToken;
+ return this;
+ }
+
+ public Builder httpTimeoutMillis(int httpTimeoutMillis) {
+ if (httpTimeoutMillis <= 0) {
+ throw new OidcAuthException("httpTimeoutMillis must be positive");
+ }
+ if (httpTimeoutMillis > MAX_HTTP_TIMEOUT_MILLIS) {
+ throw new OidcAuthException()
+ .put("httpTimeoutMillis must not exceed ").put(MAX_HTTP_TIMEOUT_MILLIS)
+ .put("; a token-endpoint round-trip never needs longer, and a larger value could let a ")
+ .put("slow refresh outlast the token store's cross-process lock staleness window");
+ }
+ this.httpTimeoutMillis = httpTimeoutMillis;
+ return this;
+ }
+
+ /**
+ * Pins the identity provider by its {@code issuer} origin (for example
+ * {@code https://idp.example.com}). When set, {@link #build()} rejects the explicitly configured token
+ * or device authorization endpoint if it is not on this origin - a sanity check that the endpoints you
+ * supplied belong to the issuer you intended. A provider hosting its endpoints on a different origin
+ * than its issuer (for example Google) is rejected when pinned this way; for such a provider, configure
+ * the endpoints without an issuer. Optional.
+ *
+ * {@link #fromQuestDB(String, DiscoveryOptions)} pins differently: it constrains only the endpoints the
+ * untrusted {@code /settings} response advertised (to the issuer's origin, and under its path when the
+ * issuer has one), while endpoints discovered from the provider's own {@code .well-known} are trusted
+ * wherever the issuer hosts them - so discovery against an off-origin provider like Google works.
+ */
+ public Builder issuer(String issuer) {
+ this.issuer = issuer;
+ return this;
+ }
+
+ /**
+ * Sets how the device code challenge is shown to the user. Defaults to
+ * {@link DeviceCodePrompt#openBrowser()} - prints to {@code System.out} and also opens the
+ * verification URL in a browser when one is available; pass {@link DeviceCodePrompt#SYSTEM_OUT}
+ * to print only.
+ */
+ public Builder prompt(DeviceCodePrompt prompt) {
+ this.prompt = prompt != null ? prompt : DeviceCodePrompt.openBrowser();
+ return this;
+ }
+
+ public Builder scope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ public Builder tlsConfig(ClientTlsConfiguration tlsConfig) {
+ this.tlsConfig = tlsConfig;
+ return this;
+ }
+
+ public Builder tokenEndpoint(String tokenEndpoint) {
+ this.tokenEndpoint = tokenEndpoint;
+ return this;
+ }
+
+ /**
+ * Persists the obtained token through the given {@link TokenStore}, so a restarted process can resume
+ * from the saved refresh token instead of running the device flow again. Defaults to {@code null}
+ * (in-memory only). Use {@link FileTokenStore#atDefaultLocation()} for the default file-backed store,
+ * or supply your own to back persistence with an OS keychain or a secrets manager. Optional.
+ */
+ public Builder tokenStore(TokenStore tokenStore) {
+ this.tokenStore = tokenStore;
+ return this;
+ }
+ }
+
+ /**
+ * Options for {@link #fromQuestDB(String, DiscoveryOptions)}: how to pin the identity provider
+ * (issuer), the TLS configuration for discovery and sign-in, whether to permit insecure {@code http},
+ * and how to show the device code challenge. Every option is optional; an instance with nothing set
+ * behaves like {@link #fromQuestDB(String)}.
+ */
+ public static final class DiscoveryOptions {
+ private boolean allowInsecureTransport;
+ private String issuer;
+ private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser();
+ private ClientTlsConfiguration tlsConfig;
+ private TokenStore tokenStore;
+
+ /**
+ * Permits insecure {@code http} for the QuestDB server link only (the {@code /settings} discovery
+ * request). It does not relax the identity provider endpoints, which always require
+ * {@code https} unless they are loopback, so the device code and refresh token are never sent in
+ * cleartext. Enable only for local development on a trusted network. Defaults to {@code false}.
+ */
+ public DiscoveryOptions allowInsecureTransport(boolean allowInsecureTransport) {
+ this.allowInsecureTransport = allowInsecureTransport;
+ return this;
+ }
+
+ /**
+ * Pins the identity provider by its {@code issuer} origin (for example
+ * {@code https://idp.example.com}). It plays two roles: when the server does not advertise the
+ * device authorization endpoint, it is discovered from the issuer's
+ * {@code .well-known/openid-configuration} (the discovery origin comes only from this out-of-band
+ * issuer, never from {@code /settings}); and it constrains the endpoints the untrusted
+ * {@code /settings} response advertised - they must be on the issuer's origin, and under its path when
+ * the issuer has one, so a tampered {@code /settings} cannot redirect credentials to a different origin
+ * or to a different tenant on a path-based provider (for example a Keycloak realm path like
+ * {@code /realms/acme}). Endpoints discovered from the provider's own {@code .well-known} are trusted
+ * wherever the issuer hosts them, so an identity provider that serves its endpoints from a different
+ * origin than its issuer (for example Google) works through discovery. Optional.
+ */
+ public DiscoveryOptions issuer(String issuer) {
+ this.issuer = issuer;
+ return this;
+ }
+
+ /**
+ * Sets how the device code challenge is shown to the user. Defaults to
+ * {@link DeviceCodePrompt#openBrowser()} - prints to {@code System.out} and also opens the
+ * verification URL in a browser when one is available; pass {@link DeviceCodePrompt#SYSTEM_OUT}
+ * to print only.
+ */
+ public DiscoveryOptions prompt(DeviceCodePrompt prompt) {
+ this.prompt = prompt != null ? prompt : DeviceCodePrompt.openBrowser();
+ return this;
+ }
+
+ /**
+ * Sets the TLS configuration used for the {@code /settings} discovery request, any identity
+ * provider discovery document, and the later sign-in requests. Defaults to full validation.
+ */
+ public DiscoveryOptions tlsConfig(ClientTlsConfiguration tlsConfig) {
+ this.tlsConfig = tlsConfig;
+ return this;
+ }
+
+ /**
+ * Persists the obtained token through the given {@link TokenStore}, so a restarted process can resume
+ * from the saved refresh token instead of running the device flow again. Defaults to {@code null}
+ * (in-memory only). See {@link FileTokenStore#atDefaultLocation()} for the default file-backed store.
+ */
+ public DiscoveryOptions tokenStore(TokenStore tokenStore) {
+ this.tokenStore = tokenStore;
+ return this;
+ }
+ }
+
+ private static final class DeviceAuthorizationResponseParser implements JsonParser, Mutable {
+ private static final int FIELD_DEVICE_CODE = 1;
+ private static final int FIELD_ERROR = 7;
+ private static final int FIELD_ERROR_DESCRIPTION = 8;
+ private static final int FIELD_EXPIRES_IN = 5;
+ private static final int FIELD_INTERVAL = 6;
+ private static final int FIELD_NONE = 0;
+ private static final int FIELD_USER_CODE = 2;
+ private static final int FIELD_VERIFICATION_URI = 3;
+ private static final int FIELD_VERIFICATION_URI_COMPLETE = 4;
+ final StringSink deviceCode = new StringSink();
+ final StringSink error = new StringSink();
+ final StringSink errorDescription = new StringSink();
+ final StringSink userCode = new StringSink();
+ final StringSink verificationUri = new StringSink();
+ final StringSink verificationUriComplete = new StringSink();
+ int expiresIn;
+ int interval;
+ // objects nested inside a JSON array are never trusted: an array-wrapped response must not surface its
+ // element object's fields at the top-level depth. arrayDepth gates every name/value read on being 0.
+ private int arrayDepth;
+ private int depth;
+ private int field = FIELD_NONE;
+
+ void wipe() {
+ // clear() rewinds; this overwrites. The device code is a credential until it expires, and the
+ // verification URIs carry the user code, so none of it should outlive the instance that read it.
+ deviceCode.wipe();
+ error.wipe();
+ errorDescription.wipe();
+ userCode.wipe();
+ verificationUri.wipe();
+ verificationUriComplete.wipe();
+ clear();
+ }
+
+ @Override
+ public void clear() {
+ deviceCode.clear();
+ error.clear();
+ errorDescription.clear();
+ userCode.clear();
+ verificationUri.clear();
+ verificationUriComplete.clear();
+ expiresIn = 0;
+ interval = 0;
+ arrayDepth = 0;
+ depth = 0;
+ field = FIELD_NONE;
+ }
+
+ @Override
+ public void onEvent(int code, CharSequence tag, int position) {
+ switch (code) {
+ case JsonLexer.EVT_ARRAY_START:
+ arrayDepth++;
+ break;
+ case JsonLexer.EVT_ARRAY_END:
+ arrayDepth--;
+ break;
+ case JsonLexer.EVT_OBJ_START:
+ depth++;
+ break;
+ case JsonLexer.EVT_OBJ_END:
+ depth--;
+ break;
+ case JsonLexer.EVT_NAME:
+ if (arrayDepth == 0 && depth == 1) {
+ if (Chars.equals("device_code", tag)) {
+ field = FIELD_DEVICE_CODE;
+ } else if (Chars.equals("user_code", tag)) {
+ field = FIELD_USER_CODE;
+ } else if (Chars.equals("verification_uri", tag) || Chars.equals("verification_url", tag)) {
+ field = FIELD_VERIFICATION_URI;
+ } else if (Chars.equals("verification_uri_complete", tag) || Chars.equals("verification_url_complete", tag)) {
+ field = FIELD_VERIFICATION_URI_COMPLETE;
+ } else if (Chars.equals("expires_in", tag)) {
+ field = FIELD_EXPIRES_IN;
+ } else if (Chars.equals("interval", tag)) {
+ field = FIELD_INTERVAL;
+ } else if (Chars.equals("error", tag)) {
+ field = FIELD_ERROR;
+ } else if (Chars.equals("error_description", tag)) {
+ field = FIELD_ERROR_DESCRIPTION;
+ } else {
+ field = FIELD_NONE;
+ }
+ }
+ break;
+ case JsonLexer.EVT_VALUE:
+ if (arrayDepth == 0 && depth == 1) {
+ switch (field) {
+ case FIELD_DEVICE_CODE:
+ putNonNull(deviceCode, tag);
+ break;
+ case FIELD_USER_CODE:
+ putNonNull(userCode, tag);
+ break;
+ case FIELD_VERIFICATION_URI:
+ putNonNull(verificationUri, tag);
+ break;
+ case FIELD_VERIFICATION_URI_COMPLETE:
+ putNonNull(verificationUriComplete, tag);
+ break;
+ case FIELD_EXPIRES_IN:
+ expiresIn = parseIntOrZero(tag);
+ break;
+ case FIELD_INTERVAL:
+ interval = parseIntOrZero(tag);
+ break;
+ case FIELD_ERROR:
+ putNonNull(error, tag);
+ break;
+ case FIELD_ERROR_DESCRIPTION:
+ putNonNull(errorDescription, tag);
+ break;
+ default:
+ break;
+ }
+ }
+ field = FIELD_NONE;
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ private static final class Endpoint {
+ final String host;
+ final boolean isTls;
+ final String path;
+ final int port;
+
+ private Endpoint(String host, int port, String path, boolean isTls) {
+ this.host = host;
+ this.port = port;
+ this.path = path;
+ this.isTls = isTls;
+ }
+
+ static Endpoint parse(String url) {
+ if (url == null) {
+ throw new OidcAuthException("url is required");
+ }
+ // Reject control characters, whitespace and display-unsafe code points anywhere in the url
+ // before it is split or used. A smuggled CR/LF (or other control char) in the host corrupts the
+ // outbound Host header; in the path or query it injects into the HTTP request line (postForm
+ // sends the path verbatim via .url(endpoint.path)) - a request-smuggling / header-injection
+ // vector when the url comes from a tampered /settings or discovery document. A bidi, zero-width
+ // or other format char (isUnsafeForDisplay, scanned per code point so a supplementary-plane one
+ // is not missed) reorders, hides or forges text when the url is echoed into a log line or the
+ // parse errors below. Rejecting up front keeps the raw url safe on the wire and on screen.
+ for (int i = 0, n = url.length(); i < n; ) {
+ final int cp = url.codePointAt(i);
+ if (cp <= ' ' || OidcAuthException.isUnsafeForDisplay(cp)) {
+ throw new OidcAuthException().put("invalid url, it contains an illegal character [url=").put(sanitizeForDisplay(url)).put(']');
+ }
+ i += Character.charCount(cp);
+ }
+ // reject a fragment (#...): it has no meaning in an endpoint url (RFC 3986 fragments are client-side
+ // only, never sent to a server), and folding it into the path opens a pin-bypass - pathOnly() strips
+ // it before the issuer-path check while postForm sends endpoint.path verbatim on the wire, so a
+ // lenient server that normalizes a '..' hidden past the '#' (POST /realms/acme#/../other/token) could
+ // resolve the request-target to a path the issuer-path pin never validated. Fail closed instead.
+ if (url.indexOf('#') >= 0) {
+ throw new OidcAuthException().put("invalid url, a fragment (#) is not supported [url=").put(url).put(']');
+ }
+ // reject a query (?...) for the same pin-bypass reason as the fragment above: pathOnly() strips it
+ // before the issuer-path check, yet postForm sends endpoint.path - query included - verbatim on the
+ // wire, so a tampered /settings could advertise an endpoint carrying a query the issuer-path pin
+ // never validated (and a lenient server could even normalize a '..' hidden past the '?'). An OIDC
+ // device/token endpoint carries its parameters in the request body (application/x-www-form-urlencoded),
+ // never the url query - RFC 6749 3.2 permits a query component but no real provider uses one here - so
+ // fail closed. The user-facing verification url, which legitimately carries the user code as a query,
+ // is parsed by BrowserLauncher (java.net.URI), not this method, so it is unaffected.
+ if (url.indexOf('?') >= 0) {
+ throw new OidcAuthException().put("invalid url, a query (?) is not supported [url=").put(url).put(']');
+ }
+ int schemeEnd = url.indexOf("://");
+ if (schemeEnd < 0) {
+ throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']');
+ }
+ boolean isTls;
+ // lower-case the scheme before matching: RFC 3986 schemes are case-insensitive, so HTTPS/Http are
+ // valid. toLowerCase(Locale.ROOT) folds only ASCII case, so a homoglyph scheme (a long-s for the s,
+ // say) does NOT fold onto http/https and still falls through to the reject below.
+ String scheme = url.substring(0, schemeEnd).toLowerCase(Locale.ROOT);
+ if ("https".equals(scheme)) {
+ isTls = true;
+ } else if ("http".equals(scheme)) {
+ isTls = false;
+ } else {
+ throw new OidcAuthException().put("invalid url, expected http or https [url=").put(url).put(']');
+ }
+ int hostStart = schemeEnd + 3;
+ // the authority ([userinfo@]host[:port]) ends at the first '/', or at the end of the url for a
+ // path-less endpoint. A ?query or #fragment was already rejected above, so neither can fold into the
+ // host or the path here (this used to also split on '?'/'#' to guard that, now handled up front).
+ int authorityEnd = url.length();
+ for (int i = hostStart, n = url.length(); i < n; i++) {
+ if (url.charAt(i) == '/') {
+ authorityEnd = i;
+ break;
+ }
+ }
+ String hostPort = url.substring(hostStart, authorityEnd);
+ // a path-less url uses '/'; otherwise the authority is '/'-terminated and the path starts at that
+ // slash, which already carries its own leading slash
+ String path = authorityEnd == url.length() ? "/" : url.substring(authorityEnd);
+ if (hostPort.indexOf('@') >= 0) {
+ // userinfo (user[:pass]@host) is unsupported: the HTTP layer would connect to the literal
+ // "user@host". Reject it clearly rather than mis-resolve it or surface a misleading port error
+ throw new OidcAuthException().put("invalid url, userinfo (user@host) is not supported [url=").put(url).put(']');
+ }
+ if (hostPort.startsWith("[")) {
+ // bracketed IPv6 literal: the client's HTTP layer does not bracket the Host header, so
+ // reject it clearly rather than mis-parse it on a ':' inside the address
+ throw new OidcAuthException().put("invalid url, IPv6 literal hosts are not supported [url=").put(url).put(']');
+ }
+ int colon = hostPort.indexOf(':');
+ String host;
+ int port;
+ if (colon >= 0) {
+ host = hostPort.substring(0, colon);
+ String portStr = hostPort.substring(colon + 1);
+ // reject a leading '+': Integer.parseInt would read ":+443" as 443 and slip the range check,
+ // but a real authority port is bare digits. A leading '-' or any non-digit still flows to
+ // parseInt below, which rejects it (a negative fails the 1..65535 range check, a non-number
+ // throws NumberFormatException) - so only the '+' that parseInt silently accepts is caught here
+ if (portStr.isEmpty() || portStr.charAt(0) == '+') {
+ throw new OidcAuthException().put("invalid url, could not parse the port [url=").put(url).put(']');
+ }
+ try {
+ port = Integer.parseInt(portStr);
+ } catch (NumberFormatException e) {
+ throw new OidcAuthException().put("invalid url, could not parse the port [url=").put(url).put(']');
+ }
+ if (port < 1 || port > 65535) {
+ throw new OidcAuthException().put("invalid url, the port must be between 1 and 65535 [url=").put(url).put(']');
+ }
+ } else {
+ host = hostPort;
+ port = isTls ? 443 : 80;
+ }
+ if (host.isEmpty()) {
+ throw new OidcAuthException().put("invalid url, the host is empty [url=").put(url).put(']');
+ }
+ // reject a non-ASCII host. The HTTP layer hands the host to the OS resolver as raw UTF-8 with no
+ // IDNA, so a non-ASCII name would not resolve anyway; and a non-ASCII code point makes the origin-pin
+ // host compare (isSameOrigin -> String.equalsIgnoreCase) unsafe, because equalsIgnoreCase folds
+ // several non-ASCII letters (U+0130, U+0131, U+017F, U+212A, ...) onto ASCII - so a homoglyph host
+ // advertised by a tampered /settings could otherwise pass the pin against the issuer. LDH ASCII
+ // hosts, punycode (xn--...) and dotted IPv4 are all ASCII and unaffected.
+ for (int i = 0, n = host.length(); i < n; i++) {
+ char hc = host.charAt(i);
+ if (hc > 0x7f) {
+ throw new OidcAuthException().put("invalid url, the host contains a non-ASCII character [url=").put(url).put(']');
+ }
+ // reject a backslash in the host: the WHATWG URL spec folds '\' to '/', so a host like
+ // good.com\.evil.com could be re-split by a lenient consumer into a different authority. The OS
+ // resolver this client hands the host to never resolves such a name anyway, so fail closed.
+ if (hc == '\\') {
+ throw new OidcAuthException().put("invalid url, the host contains a backslash [url=").put(url).put(']');
+ }
+ }
+ return new Endpoint(host, port, path, isTls);
+ }
+ }
+
+ private static final class SettingsDiscoveryParser implements JsonParser {
+ private static final int FIELD_AUDIENCE = 7;
+ private static final int FIELD_CLIENT_ID = 2;
+ private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 5;
+ private static final int FIELD_ENABLED = 1;
+ private static final int FIELD_GROUPS_IN_TOKEN = 6;
+ private static final int FIELD_NONE = 0;
+ private static final int FIELD_SCOPE = 3;
+ private static final int FIELD_TOKEN_ENDPOINT = 4;
+ final StringSink audience = new StringSink();
+ final StringSink clientId = new StringSink();
+ final StringSink deviceAuthorizationEndpoint = new StringSink();
+ final StringSink scope = new StringSink();
+ final StringSink tokenEndpoint = new StringSink();
+ boolean groupsInToken;
+ boolean isOidcEnabled;
+ // objects nested inside a JSON array are never trusted config: track array depth and require it 0 for
+ // every name/value/config-arming decision, so a tampered {"config":[{...}]} (or a top-level array
+ // wrapper) cannot surface the array element's object at the config depth. Array VALUES are ignored
+ // regardless; legitimate array-valued config keys (never read here) are harmlessly skipped.
+ private int arrayDepth;
+ private int depth;
+ private int field = FIELD_NONE;
+ private boolean isConfigNext;
+ private boolean isInConfig;
+
+ @Override
+ public void onEvent(int code, CharSequence tag, int position) {
+ switch (code) {
+ case JsonLexer.EVT_ARRAY_START:
+ arrayDepth++;
+ break;
+ case JsonLexer.EVT_ARRAY_END:
+ arrayDepth--;
+ break;
+ case JsonLexer.EVT_OBJ_START:
+ depth++;
+ if (arrayDepth == 0 && depth == 2 && isConfigNext) {
+ isInConfig = true;
+ }
+ isConfigNext = false;
+ break;
+ case JsonLexer.EVT_OBJ_END:
+ if (depth == 2) {
+ isInConfig = false;
+ }
+ depth--;
+ break;
+ case JsonLexer.EVT_NAME:
+ if (arrayDepth == 0 && depth == 1) {
+ // only the top-level "config" object is trusted; the sibling "preferences" object
+ // holds arbitrary user-written keys and must not feed OIDC discovery
+ isConfigNext = Chars.equals("config", tag);
+ field = FIELD_NONE;
+ } else if (arrayDepth == 0 && depth == 2 && isInConfig) {
+ if (Chars.equals("acl.oidc.enabled", tag)) {
+ field = FIELD_ENABLED;
+ } else if (Chars.equals("acl.oidc.client.id", tag)) {
+ field = FIELD_CLIENT_ID;
+ } else if (Chars.equals("acl.oidc.scope", tag)) {
+ field = FIELD_SCOPE;
+ } else if (Chars.equals("acl.oidc.token.endpoint", tag)) {
+ field = FIELD_TOKEN_ENDPOINT;
+ } else if (Chars.equals("acl.oidc.device.authorization.endpoint", tag)) {
+ field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT;
+ } else if (Chars.equals("acl.oidc.groups.encoded.in.token", tag)) {
+ field = FIELD_GROUPS_IN_TOKEN;
+ } else if (Chars.equals("acl.oidc.audience", tag)) {
+ field = FIELD_AUDIENCE;
+ } else {
+ field = FIELD_NONE;
+ }
+ } else {
+ field = FIELD_NONE;
+ }
+ break;
+ case JsonLexer.EVT_VALUE:
+ if (arrayDepth == 0 && depth == 2 && isInConfig) {
+ switch (field) {
+ case FIELD_ENABLED:
+ isOidcEnabled = Chars.equals("true", tag);
+ break;
+ case FIELD_CLIENT_ID:
+ putNonNull(clientId, tag);
+ break;
+ case FIELD_SCOPE:
+ putNonNull(scope, tag);
+ break;
+ case FIELD_TOKEN_ENDPOINT:
+ putNonNull(tokenEndpoint, tag);
+ break;
+ case FIELD_DEVICE_AUTHORIZATION_ENDPOINT:
+ putNonNull(deviceAuthorizationEndpoint, tag);
+ break;
+ case FIELD_GROUPS_IN_TOKEN:
+ groupsInToken = Chars.equals("true", tag);
+ break;
+ case FIELD_AUDIENCE:
+ putNonNull(audience, tag);
+ break;
+ default:
+ break;
+ }
+ }
+ field = FIELD_NONE;
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ private static final class TokenResponseParser implements JsonParser, Mutable {
+ private static final int FIELD_ACCESS_TOKEN = 1;
+ private static final int FIELD_ERROR = 6;
+ private static final int FIELD_ERROR_DESCRIPTION = 7;
+ private static final int FIELD_EXPIRES_IN = 4;
+ private static final int FIELD_ID_TOKEN = 2;
+ private static final int FIELD_NONE = 0;
+ private static final int FIELD_REFRESH_TOKEN = 3;
+ final StringSink accessToken = new StringSink();
+ final StringSink error = new StringSink();
+ final StringSink errorDescription = new StringSink();
+ final StringSink idToken = new StringSink();
+ final StringSink refreshToken = new StringSink();
+ int expiresIn;
+ // objects nested inside a JSON array are never trusted: an array-wrapped response must not surface its
+ // element object's fields at the top-level depth. arrayDepth gates every name/value read on being 0.
+ private int arrayDepth;
+ private int depth;
+ private int field = FIELD_NONE;
+
+ void wipe() {
+ // clear() rewinds; this overwrites. These five sinks hold the raw grant: the access token, the id
+ // token and the refresh token, exactly as the identity provider sent them.
+ accessToken.wipe();
+ error.wipe();
+ errorDescription.wipe();
+ idToken.wipe();
+ refreshToken.wipe();
+ clear();
+ }
+
+ @Override
+ public void clear() {
+ accessToken.clear();
+ error.clear();
+ errorDescription.clear();
+ idToken.clear();
+ refreshToken.clear();
+ expiresIn = 0;
+ arrayDepth = 0;
+ depth = 0;
+ field = FIELD_NONE;
+ }
+
+ @Override
+ public void onEvent(int code, CharSequence tag, int position) {
+ switch (code) {
+ case JsonLexer.EVT_ARRAY_START:
+ arrayDepth++;
+ break;
+ case JsonLexer.EVT_ARRAY_END:
+ arrayDepth--;
+ break;
+ case JsonLexer.EVT_OBJ_START:
+ depth++;
+ break;
+ case JsonLexer.EVT_OBJ_END:
+ depth--;
+ break;
+ case JsonLexer.EVT_NAME:
+ if (arrayDepth == 0 && depth == 1) {
+ if (Chars.equals("access_token", tag)) {
+ field = FIELD_ACCESS_TOKEN;
+ } else if (Chars.equals("id_token", tag)) {
+ field = FIELD_ID_TOKEN;
+ } else if (Chars.equals("refresh_token", tag)) {
+ field = FIELD_REFRESH_TOKEN;
+ } else if (Chars.equals("expires_in", tag)) {
+ field = FIELD_EXPIRES_IN;
+ } else if (Chars.equals("error", tag)) {
+ field = FIELD_ERROR;
+ } else if (Chars.equals("error_description", tag)) {
+ field = FIELD_ERROR_DESCRIPTION;
+ } else {
+ field = FIELD_NONE;
+ }
+ }
+ break;
+ case JsonLexer.EVT_VALUE:
+ if (arrayDepth == 0 && depth == 1) {
+ switch (field) {
+ case FIELD_ACCESS_TOKEN:
+ putNonNull(accessToken, tag);
+ break;
+ case FIELD_ID_TOKEN:
+ putNonNull(idToken, tag);
+ break;
+ case FIELD_REFRESH_TOKEN:
+ putNonNull(refreshToken, tag);
+ break;
+ case FIELD_EXPIRES_IN:
+ expiresIn = parseIntOrZero(tag);
+ break;
+ case FIELD_ERROR:
+ putNonNull(error, tag);
+ break;
+ case FIELD_ERROR_DESCRIPTION:
+ putNonNull(errorDescription, tag);
+ break;
+ default:
+ break;
+ }
+ }
+ field = FIELD_NONE;
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ private static final class WellKnownDiscoveryParser implements JsonParser {
+ private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 1;
+ private static final int FIELD_NONE = 0;
+ private static final int FIELD_TOKEN_ENDPOINT = 2;
+ final StringSink deviceAuthorizationEndpoint = new StringSink();
+ final StringSink tokenEndpoint = new StringSink();
+ // objects nested inside a JSON array are never trusted: an array-wrapped document must not surface its
+ // element object's fields at the top-level depth. arrayDepth gates every name/value read on being 0.
+ private int arrayDepth;
+ private int depth;
+ private int field = FIELD_NONE;
+
+ @Override
+ public void onEvent(int code, CharSequence tag, int position) {
+ switch (code) {
+ case JsonLexer.EVT_ARRAY_START:
+ arrayDepth++;
+ break;
+ case JsonLexer.EVT_ARRAY_END:
+ arrayDepth--;
+ break;
+ case JsonLexer.EVT_OBJ_START:
+ depth++;
+ break;
+ case JsonLexer.EVT_OBJ_END:
+ depth--;
+ break;
+ case JsonLexer.EVT_NAME:
+ // the OIDC discovery document is a flat top-level object; only read top-level keys so a
+ // nested value cannot be mistaken for an endpoint
+ if (arrayDepth == 0 && depth == 1) {
+ if (Chars.equals("device_authorization_endpoint", tag)) {
+ field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT;
+ } else if (Chars.equals("token_endpoint", tag)) {
+ field = FIELD_TOKEN_ENDPOINT;
+ } else {
+ field = FIELD_NONE;
+ }
+ }
+ break;
+ case JsonLexer.EVT_VALUE:
+ if (arrayDepth == 0 && depth == 1) {
+ switch (field) {
+ case FIELD_DEVICE_AUTHORIZATION_ENDPOINT:
+ putNonNull(deviceAuthorizationEndpoint, tag);
+ break;
+ case FIELD_TOKEN_ENDPOINT:
+ putNonNull(tokenEndpoint, tag);
+ break;
+ default:
+ break;
+ }
+ }
+ field = FIELD_NONE;
+ break;
+ default:
+ break;
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/PersistedToken.java b/core/src/main/java/io/questdb/client/cutlass/auth/PersistedToken.java
new file mode 100644
index 000000000..91c73770f
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/PersistedToken.java
@@ -0,0 +1,71 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * An immutable snapshot of the token state an {@link OidcDeviceAuth} holds, passed to and from a
+ * {@link TokenStore} so the device flow does not have to be re-run after a process restart. Mirrors
+ * the in-memory fields: the access token, the id token, the refresh token, the absolute wall-clock
+ * expiry of the access/id token, and the (clamped) lifetime that expiry was derived from.
+ *
+ * Any of the three token strings may be {@code null}. {@link #getExpiresAtMillis()} is an absolute
+ * {@code System.currentTimeMillis()} value, so it remains meaningful across a restart (unlike a
+ * monotonic clock reading).
+ */
+public final class PersistedToken {
+ private final String accessToken;
+ private final long expiresAtMillis;
+ private final String idToken;
+ private final String refreshToken;
+ private final long tokenTtlMillis;
+
+ public PersistedToken(String accessToken, String idToken, String refreshToken, long expiresAtMillis, long tokenTtlMillis) {
+ this.accessToken = accessToken;
+ this.idToken = idToken;
+ this.refreshToken = refreshToken;
+ this.expiresAtMillis = expiresAtMillis;
+ this.tokenTtlMillis = tokenTtlMillis;
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public long getExpiresAtMillis() {
+ return expiresAtMillis;
+ }
+
+ public String getIdToken() {
+ return idToken;
+ }
+
+ public String getRefreshToken() {
+ return refreshToken;
+ }
+
+ public long getTokenTtlMillis() {
+ return tokenTtlMillis;
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java
new file mode 100644
index 000000000..631b68a22
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java
@@ -0,0 +1,146 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * Persists the token state of an {@link OidcDeviceAuth} so a restarted process can resume from a saved
+ * refresh token instead of running the interactive device flow again. Persistence is opt-in: an
+ * {@code OidcDeviceAuth} with no store keeps its tokens in memory only (the previous behaviour).
+ *
+ * The default implementation is {@link FileTokenStore} (a strict-permissions file under the user's home
+ * directory). Supply your own to back persistence with an OS keychain, a secrets manager, or a vault -
+ * for example to encrypt the refresh token at rest, which the file store does not do.
+ *
+ * Entries are keyed by {@link TokenStoreKey} (the non-secret identity: endpoints, client id, scope,
+ * audience, groups-in-token mode), so a token minted for one identity is never returned for another.
+ * {@code TokenStoreKey} is a value type - it implements {@code equals}/{@code hashCode} over that
+ * identity - so an implementation may hold its entries in a {@code Map} keyed by it directly. A store
+ * that needs a stable name instead (a file, a keychain entry, a row id) should use
+ * {@link TokenStoreKey#hash()}, which is the same identity as an opaque hex string and is stable across
+ * processes and across QuestDB's client implementations in other languages.
+ * Calls are made while {@code OidcDeviceAuth} holds its own instance lock, so an implementation does not
+ * need to be thread-safe against concurrent calls from one {@code OidcDeviceAuth} instance; it does,
+ * however, share its backing storage with other processes (and other language clients), so it must keep a
+ * concurrent reader from observing a half-written entry - see {@link FileTokenStore} for how the file
+ * store does that and coordinates a rotating refresh token across processes.
+ *
+ * A store reports a failure by throwing; {@code OidcDeviceAuth} treats persistence as best-effort and a
+ * thrown failure as non-fatal - it logs a warning through SLF4J at WARN and continues with the in-memory
+ * token, which is valid regardless of whether it could be saved.
+ */
+public interface TokenStore {
+ /**
+ * Removes any persisted entry for this identity. Called from {@link OidcDeviceAuth#clearCache()}.
+ * A no-op when nothing is stored.
+ *
+ * @param key the identity whose entry to remove
+ */
+ void clear(TokenStoreKey key);
+
+ /**
+ * Runs {@code action} while holding a cross-process lock scoped to {@code key}, so a refresh by
+ * another process sharing this identity is observed rather than raced. The action re-reads the store
+ * inside the lock and refreshes only if still needed, which keeps a rotating refresh token consistent
+ * across processes.
+ *
+ * The default runs {@code action} with no locking, which is correct for a single process or a
+ * non-rotating refresh token; {@link FileTokenStore} overrides it with a lock-file protocol. An
+ * implementation that cannot acquire the lock should run {@code action} anyway (degrade) rather than
+ * fail a sign-in.
+ *
+ * {@code OidcDeviceAuth} calls this while holding its own instance lock, and {@code action} runs
+ * synchronously on the calling thread. An implementation therefore must not call back into the owning
+ * {@code OidcDeviceAuth} (for example {@code signIn()}/{@code getToken()}) from {@code inLock},
+ * {@code load}, or {@code save}, and must not block waiting on another thread that could need that
+ * instance lock - either would re-enter or deadlock. Do the store I/O only.
+ *
+ * Like {@code load} and {@code save}, this is BEST-EFFORT: an implementation that throws must not be
+ * able to fail a sign-in the caller could otherwise complete. {@code OidcDeviceAuth} therefore degrades
+ * on a throw rather than propagating it, and what it does depends on whether {@code action} ran: a
+ * throw before the action runs falls back to a single uncoordinated refresh, while a throw after the
+ * action completed - releasing a lock, closing a handle - keeps the action's result, because the
+ * refresh already happened and re-running it is the duplicate POST of a rotating refresh token this
+ * lock exists to prevent. An exception from {@code action} itself is the caller's own and propagates
+ * untouched. An implementation should still absorb its own bookkeeping failures and degrade to running
+ * {@code action} unlocked, rather than lean on that fallback.
+ *
+ * An implementation that waits for its lock must make that wait INTERRUPTIBLE and, on an interrupt,
+ * return {@code false} without running {@code action} AND leave the thread's interrupt flag SET. The
+ * wait can outlast the caller's own shutdown budget - QWP's connect cancellation interrupts a thread
+ * stuck in a credential pull precisely so {@code close()} can reclaim its native resources - and an
+ * uninterruptible wait defeats that, leaving the client, the cursor engine and the store-and-forward
+ * slot lock to a delegated teardown.
+ *
+ * Restoring the flag is not optional politeness. {@code false} on its own is indistinguishable from
+ * "the refresh ran and failed", and {@code OidcDeviceAuth} must tell the two apart: it reads a plain
+ * {@code false} as a failed refresh and answers by starting the INTERACTIVE device flow - a browser
+ * launch and a poll loop that runs to the device-code lifetime, ignoring interrupts - on a thread whose
+ * owner has already cancelled it, and by arming the shared refresh back-off that then fails every other
+ * caller of the instance. An implementation that consumes the interrupt (as
+ * {@code InterruptedException} does) must re-assert it with {@code Thread.currentThread().interrupt()}
+ * before returning, once it is past any interruptible I/O of its own.
+ *
+ * @param key the identity to lock
+ * @param action the critical section; its boolean result is returned unchanged
+ * @return whatever {@code action} returned, or {@code false} if an interrupt abandoned the wait before
+ * {@code action} could run
+ */
+ default boolean inLock(TokenStoreKey key, CriticalSection action) {
+ return action.run();
+ }
+
+ /**
+ * Loads the persisted token for this identity, or returns {@code null} if there is none usable (no
+ * entry, or an entry that does not match {@code key}, or one that cannot be read as a valid token).
+ * A {@code null} return makes {@code OidcDeviceAuth} fall back to a refresh or an interactive sign-in,
+ * so an unreadable or stale entry is recoverable rather than fatal.
+ *
+ * Returning {@code null} is the definitive answer, and ends the reads for the life of that
+ * {@code OidcDeviceAuth}. Throwing is not: it reads as a transient fault and is retried - immediately
+ * once, then behind a back-off that grows to a minute, so an implementation that can never succeed is
+ * not re-entered on every {@code getToken()} call (which an ILP producer makes once per flush).
+ *
+ * @param key the identity to load
+ * @return the persisted token, or {@code null}
+ */
+ PersistedToken load(TokenStoreKey key);
+
+ /**
+ * Persists (atomically replaces) the token for this identity.
+ *
+ * @param key the identity to store under
+ * @param token the token state to persist
+ */
+ void save(TokenStoreKey key, PersistedToken token);
+
+ /**
+ * A unit of work {@link #inLock(TokenStoreKey, CriticalSection)} runs while holding the per-identity
+ * lock. Returns whether a valid token resulted.
+ */
+ @FunctionalInterface
+ interface CriticalSection {
+ boolean run();
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java
new file mode 100644
index 000000000..b30489e8c
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java
@@ -0,0 +1,198 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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 java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * The non-secret identity a persisted token belongs to: the client id, the (canonicalised) token and
+ * device-authorization endpoints, the scope, the optional audience, and whether the server expects
+ * groups encoded in the token. A {@link TokenStore} keys its entries by this so a token minted for one
+ * server / identity provider / scope / audience is never served to a process configured for another.
+ *
+ * {@link #hash()} is a stable, lowercase-hex SHA-256 over a canonical, NUL-separated rendering of the
+ * fields - intended as a file name (or any opaque key) that is identical across client implementations
+ * (the Python client mirrors this), so several processes - and languages - sharing one identity address
+ * the same persisted entry. The fields themselves are exposed (they are not secret) so a store can also
+ * record them and re-check them on load as a defence against a hash collision or a copied file.
+ */
+public final class TokenStoreKey {
+ // the canonical-string prefix doubles as a domain tag and a schema version, so a future format change
+ // produces a different hash (and hence a different file) rather than silently colliding with v1 entries
+ private static final String CANONICAL_PREFIX = "questdb-oidc-token-v1";
+ private static final char[] HEX = "0123456789abcdef".toCharArray();
+ private final String audience;
+ private final String clientId;
+ private final String deviceAuthorizationEndpoint;
+ private final boolean groupsInToken;
+ private final String hash;
+ private final String scope;
+ private final String tokenEndpoint;
+
+ /**
+ * @param clientId the OIDC client id
+ * @param tokenEndpoint the canonical token endpoint ({@code scheme://host:port/path},
+ * scheme and host lower-cased, port explicit)
+ * @param deviceAuthorizationEndpoint the canonical device-authorization endpoint, same form
+ * @param scope the requested scope
+ * @param audience the audience, or {@code null} if none
+ * @param groupsInToken whether the id token (rather than the access token) is served
+ */
+ public TokenStoreKey(
+ String clientId,
+ String tokenEndpoint,
+ String deviceAuthorizationEndpoint,
+ String scope,
+ String audience,
+ boolean groupsInToken
+ ) {
+ // the identity fields are required; reject a null up front with a clear error rather than letting it
+ // surface later as a raw NullPointerException deep inside a TokenStore's serialize/fingerprint path
+ if (clientId == null || tokenEndpoint == null || deviceAuthorizationEndpoint == null || scope == null) {
+ throw new OidcAuthException(
+ "clientId, tokenEndpoint, deviceAuthorizationEndpoint and scope are required for a token store key");
+ }
+ this.clientId = clientId;
+ this.tokenEndpoint = tokenEndpoint;
+ this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
+ this.scope = scope;
+ // normalise an empty audience to null so getAudience(), hash() (which already folds null and "" together
+ // via nullToEmpty), and a TokenStore's save/load round-trip all agree that an absent audience is null -
+ // matching how OidcDeviceAuth builds the key
+ this.audience = audience != null && !audience.isEmpty() ? audience : null;
+ this.groupsInToken = groupsInToken;
+ this.hash = computeHash(clientId, tokenEndpoint, deviceAuthorizationEndpoint, scope, this.audience, groupsInToken);
+ }
+
+ /**
+ * Value equality over the identity this key names, so a {@link TokenStore} may hold its entries in a
+ * {@code Map} keyed by this type - which the {@link TokenStore} contract ("entries are keyed by
+ * {@link TokenStoreKey}") invites, and which identity equality would silently defeat: {@code
+ * OidcDeviceAuth} builds its key once per instance, so a Map-backed store appears to work until a
+ * second instance or a restart rebuilds an equal key and misses, sending the user back through the
+ * device flow on every refresh.
+ *
+ * Compares {@link #hash()} rather than the fields one by one, so equality means exactly "the same
+ * store entry": the hash folds every identity field through the same null-vs-empty normalization the
+ * constructor applies, so two keys that address one entry are equal here even when their raw
+ * arguments differed in that respect.
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof TokenStoreKey)) {
+ return false;
+ }
+ return hash.equals(((TokenStoreKey) o).hash);
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public String getDeviceAuthorizationEndpoint() {
+ return deviceAuthorizationEndpoint;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+
+ public String getTokenEndpoint() {
+ return tokenEndpoint;
+ }
+
+ /**
+ * @return a stable lowercase-hex SHA-256 of the canonical identity string; suitable as an opaque file
+ * name. Identical inputs (across processes and language implementations) yield an identical hash.
+ */
+ public String hash() {
+ return hash;
+ }
+
+ /**
+ * Consistent with {@link #equals(Object)}: both derive from {@link #hash()}, which is a pure function
+ * of the identity fields.
+ */
+ @Override
+ public int hashCode() {
+ return hash.hashCode();
+ }
+
+ public boolean isGroupsInToken() {
+ return groupsInToken;
+ }
+
+ private static String computeHash(
+ String clientId,
+ String tokenEndpoint,
+ String deviceAuthorizationEndpoint,
+ String scope,
+ String audience,
+ boolean groupsInToken
+ ) {
+ // NUL-separate the fields so no field value can be confused with a separator; an OAuth client id,
+ // url, scope or audience never contains a NUL. The prefix tags the domain and schema version.
+ StringBuilder canonical = new StringBuilder();
+ canonical.append(CANONICAL_PREFIX).append('\0')
+ .append(nullToEmpty(clientId)).append('\0')
+ .append(nullToEmpty(tokenEndpoint)).append('\0')
+ .append(nullToEmpty(deviceAuthorizationEndpoint)).append('\0')
+ .append(nullToEmpty(scope)).append('\0')
+ .append(nullToEmpty(audience)).append('\0')
+ .append(groupsInToken ? '1' : '0');
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] bytes = digest.digest(canonical.toString().getBytes(StandardCharsets.UTF_8));
+ return toHex(bytes);
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandated on every JVM, so this is unreachable; rethrow defensively rather than
+ // declare a checked exception across the whole construction path
+ throw new OidcAuthException(e).put("SHA-256 is not available to key the OIDC token store");
+ }
+ }
+
+ private static String nullToEmpty(String s) {
+ return s != null ? s : "";
+ }
+
+ private static String toHex(byte[] bytes) {
+ char[] out = new char[bytes.length * 2];
+ for (int i = 0; i < bytes.length; i++) {
+ int v = bytes[i] & 0xff;
+ out[i * 2] = HEX[v >>> 4];
+ out[i * 2 + 1] = HEX[v & 0x0f];
+ }
+ return new String(out);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java
index ee9995596..3a22b2805 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java
@@ -35,6 +35,11 @@
*/
public abstract class AbstractChunkedResponse implements Response, Fragment {
private final static int CRLF_LEN = 2;
+ // Most hex digits a chunk-size line may carry once leading zeros are stripped. 16^15 - 1 is about
+ // 1.15e18 and always fits a long; a 16th digit can push it past Long.MAX_VALUE and wrap. The
+ // smallest thing this turns away is a 2^60-byte (1 EiB) chunk, so it costs no real server
+ // anything, and it rejects absurd-but-representable sizes that a mere overflow check admits.
+ private static final int MAX_CHUNK_SIZE_HEX_DIGITS = 15;
private static final int STATE_CHUNK_DATA = 1;
private static final int STATE_CHUNK_DATA_END = 2;
private static final int STATE_CHUNK_SIZE = 0;
@@ -91,10 +96,30 @@ public long lo() {
}
public Fragment recv(int timeout) {
+ // A positive timeout bounds the whole call, not each socket read. This loop re-reads while a
+ // chunk-size line (or the chunk-data-end CRLF) is incomplete, so without one shared deadline a server
+ // dribbling those bytes - one per timeout window - would run a single recv() for (line length) x
+ // timeout and defeat a caller's elapsed-time bound (e.g. OidcDeviceAuth.parseBody). A non-positive
+ // timeout keeps the legacy "no bound" behaviour.
+ final boolean bounded = timeout > 0;
+ final long startNanos = bounded ? System.nanoTime() : 0L;
while (true) {
+ // Consult the deadline on EVERY pass, not only on the passes that read. A pass that neither
+ // reads nor advances the state machine re-enters the loop with receive == false and
+ // dataLo < dataHi, which skips the read gate below - so a deadline checked only inside that
+ // gate is never reached, and the loop spins without the bound this method promises. Keeping
+ // the check above the gate makes the bound hold for every pass, however the state machine got
+ // there.
+ int callTimeout = timeout;
+ if (bounded) {
+ callTimeout = timeout - (int) ((System.nanoTime() - startNanos) / 1_000_000L);
+ if (callTimeout <= 0) {
+ throw new HttpClientException("timed out reading the chunked response body");
+ }
+ }
if (receive || dataLo == dataHi) {
compactBuffer();
- dataHi += recvOrDie(dataHi, bufHi, timeout);
+ dataHi += recvOrDie(dataHi, bufHi, callTimeout);
}
long p; // moving data pointer for scanning buffer
switch (state) {
@@ -126,8 +151,26 @@ public Fragment recv(int timeout) {
if (res != -1) {
// at this stage we consumed the chunk size end (CRLF)
chunkSize.of(dataLo, res + 1);
+ final CharSequence chunkSizeHex = chunkSize.asAsciiCharSequence();
+ // Bound the SIZE LINE here, before parsing it. Numbers.parseHexLong wraps on
+ // overflow like every other hex-word parser in that class, which is right for a
+ // general-purpose utility and wrong for a count the peer chose - and the size line
+ // is chosen by the server, which for an OIDC discovery or token response is
+ // untrusted. Each residue breaks framing its own way: a negative one
+ // (8000000000000000 is the smallest) matches neither the "size > 0" data branch nor
+ // the "size == 0" terminator below, so the state machine loops on it forever; zero
+ // (10000000000000000) reads as the TERMINAL chunk, truncating the response and
+ // losing framing for the next keep-alive response on the connection; a positive
+ // residue frames a short data chunk and mis-reads everything after it.
+ //
+ // It has to happen BEFORE the parse, not after: the zero residue is
+ // indistinguishable from a genuine 0 once the high bits are gone, so no check on
+ // the returned value can catch the worst of the three.
+ if (isChunkSizeTooLong(chunkSizeHex)) {
+ throw new HttpClientException("malformed chunk size");
+ }
try {
- size = Numbers.parseHexLong(chunkSize.asAsciiCharSequence());
+ size = Numbers.parseHexLong(chunkSizeHex);
consumed = 0;
// consume data buffer ignoring chunk size value and its furniture
state = STATE_CHUNK_DATA;
@@ -238,5 +281,21 @@ private byte getByte(long addr) {
* @param timeout the timeout in milliseconds
* @return the number of bytes received
*/
+ /**
+ * Whether a chunk-size line carries more significant hex digits than a long can hold.
+ *
+ * Counts SIGNIFICANT digits, skipping leading zeros: {@code 0000000000000001} is sixteen characters
+ * and a perfectly ordinary size, so a raw length check would reject legitimate input from a server
+ * that pads.
+ */
+ private static boolean isChunkSizeTooLong(CharSequence hex) {
+ final int n = hex.length();
+ int i = 0;
+ while (i < n && hex.charAt(i) == '0') {
+ i++;
+ }
+ return n - i > MAX_CHUNK_SIZE_HEX_DIGITS;
+ }
+
protected abstract int recvOrDie(long bufLo, long bufHi, int timeout);
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java
index b3b521daf..d56219c6c 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java
@@ -65,9 +65,24 @@ public Fragment recv(int timeout) {
if (receive) {
dataLo = bufLo;
dataHi = bufLo;
+ // A positive timeout bounds the whole call, not each socket read. recvOrDie can return 0
+ // without consuming the full timeout - e.g. an incomplete TLS record that decrypts to no
+ // application bytes - so without one shared deadline a server dribbling such reads would
+ // re-arm the full timeout on every iteration and keep this recv() running without bound,
+ // defeating a caller's elapsed-time bound (e.g. OidcDeviceAuth.parseBody). A non-positive
+ // timeout keeps the legacy "no bound" behaviour.
+ final boolean bounded = timeout > 0;
+ final long startNanos = bounded ? System.nanoTime() : 0L;
int len = 0;
while (len == 0) {
- len = recvOrDie(dataHi, bufHi, timeout);
+ int callTimeout = timeout;
+ if (bounded) {
+ callTimeout = timeout - (int) ((System.nanoTime() - startNanos) / 1_000_000L);
+ if (callTimeout <= 0) {
+ throw new HttpClientException("timed out reading the response body");
+ }
+ }
+ len = recvOrDie(dataHi, bufHi, callTimeout);
}
dataHi += len;
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClient.java
index 0175ad6c9..941dc58ff 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClient.java
@@ -83,16 +83,40 @@ public abstract class HttpClient implements QuietCloseable {
public HttpClient(HttpClientConfiguration configuration, SocketFactory socketFactory) {
this.nf = configuration.getNetworkFacade();
- this.socket = socketFactory.newInstance(nf, LOG);
this.defaultTimeout = configuration.getTimeout();
this.connectTimeout = configuration.getConnectTimeout();
this.bufferSize = configuration.getInitialRequestBufferSize();
this.maxBufferSize = configuration.getMaximumRequestBufferSize();
this.responseParserBufSize = configuration.getResponseBufferSize();
this.fixBrokenConnection = configuration.fixBrokenConnection();
- this.bufLo = Unsafe.malloc(bufferSize, MemoryTag.NATIVE_DEFAULT);
- this.responseParserBufLo = Unsafe.malloc(responseParserBufSize, MemoryTag.NATIVE_DEFAULT);
- this.responseHeaders = new ResponseHeaders(responseParserBufLo, responseParserBufSize, defaultTimeout, 4096, csPool);
+ // Stage every acquisition and roll the lot back on any throw. A constructor that fails partway
+ // leaves an object nobody can close: it never reaches the caller, so no finally, no try-with-resources
+ // and no close() ever runs on it, and whatever it had already taken is lost for the life of the
+ // process. The two mallocs and ResponseHeaders' own buffer are native, so the loss is native memory,
+ // and the trigger is the same condition that makes these fail in the first place - memory pressure, or
+ // fd exhaustion in the socket factory. Retrying then compounds it. Kqueue already guards its
+ // constructor this way; this one did not.
+ Socket stagedSocket = null;
+ long stagedBufLo = 0;
+ long stagedResponseParserBufLo = 0;
+ try {
+ stagedSocket = socketFactory.newInstance(nf, LOG);
+ stagedBufLo = Unsafe.malloc(bufferSize, MemoryTag.NATIVE_DEFAULT);
+ stagedResponseParserBufLo = Unsafe.malloc(responseParserBufSize, MemoryTag.NATIVE_DEFAULT);
+ this.responseHeaders = new ResponseHeaders(stagedResponseParserBufLo, responseParserBufSize, defaultTimeout, 4096, csPool);
+ } catch (Throwable t) {
+ if (stagedResponseParserBufLo != 0) {
+ Unsafe.free(stagedResponseParserBufLo, responseParserBufSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ if (stagedBufLo != 0) {
+ Unsafe.free(stagedBufLo, bufferSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ Misc.free(stagedSocket);
+ throw t;
+ }
+ this.socket = stagedSocket;
+ this.bufLo = stagedBufLo;
+ this.responseParserBufLo = stagedResponseParserBufLo;
}
@Override
@@ -329,8 +353,22 @@ public int getContentLength() {
}
}
+ /**
+ * The address of the request's content section, or {@code 0} when no content section has been
+ * started - deliberately NOT the {@code -1} sentinel the field carries in that state.
+ *
+ * Callers pair this with {@link #getContentLength()}, which already reports 0 for the same state, so
+ * handing back {@code -1} here produced a view that is empty by length but whose base address is a
+ * non-zero, unusable pointer: a {@code ptr() != 0} test reads as true, and pointer arithmetic on it
+ * is nonsense. That state became reachable when withContent() started being deferred - an ILP
+ * request with an httpTokenProvider sits at the header stage until the first row stamps the
+ * Authorization header - so {@code Sender.bufferView()} returned it between every flush and the next
+ * row. {@code trimContentToLen} was guarded against the same sentinel; this accessor was not.
+ *
+ * @return the content-section address, or 0 when there is no content section
+ */
public long getContentStart() {
- return contentStart;
+ return contentStart < 0 ? 0 : contentStart;
}
public long getPtr() {
@@ -547,7 +585,27 @@ public String toString() {
return ss.toString();
}
+ /**
+ * Rewinds the write pointer to {@code contentLen} bytes into the content section, discarding
+ * whatever was written past it.
+ *
+ * A request that has not reached {@code withContent()} yet has no content section to rewind, and
+ * the sentinel guard below is the only thing standing between that state and a SIGSEGV: without it
+ * the pointer becomes {@code -1 + contentLen} and the next write to the buffer takes the process
+ * down. That state is ordinary, not exotic - an ILP request with an {@code httpTokenProvider} sits
+ * at the header stage between every flush and the next row - and {@code Request} is exported, so an
+ * external caller can reach it too. {@code HttpClientRequestTrimTest} pins it.
+ *
+ * @param contentLen the content length to rewind to
+ */
public void trimContentToLen(int contentLen) {
+ if (contentStart < 0) {
+ // withContent() has not started a content section yet, so contentStart is the -1 sentinel
+ // and contentStart + contentLen would be a negative, invalid write pointer that the next
+ // write would segfault on. Nothing has been written into a content section, so there is
+ // nothing to trim.
+ return;
+ }
ptr = contentStart + contentLen;
}
@@ -853,9 +911,20 @@ public class ResponseHeaders extends HttpHeaderParser {
public ResponseHeaders(long respParserBufLo, int respParserBufSize, int defaultTimeout, int headerBufSize, ObjectPool pool) {
super(headerBufSize, pool);
- this.defaultTimeout = defaultTimeout;
- this.response = new ResponseImpl(respParserBufLo, respParserBufLo + respParserBufSize, defaultTimeout);
- this.chunkedResponse = new ChunkedResponseImpl(respParserBufLo, respParserBufLo + respParserBufSize, defaultTimeout);
+ // super() mallocs the header parse buffer as its FIRST statement, so from here on this object owns
+ // native memory while still being unreachable by anyone who could free it. A heap OOM in either
+ // allocation below would strand those bytes past the enclosing constructor's catch (Throwable),
+ // which frees only what IT staged - it never holds a reference to a ResponseHeaders that failed
+ // to finish constructing. Same rule as out there: whoever took it frees it when construction
+ // cannot complete.
+ try {
+ this.defaultTimeout = defaultTimeout;
+ this.response = new ResponseImpl(respParserBufLo, respParserBufLo + respParserBufSize, defaultTimeout);
+ this.chunkedResponse = new ChunkedResponseImpl(respParserBufLo, respParserBufLo + respParserBufSize, defaultTimeout);
+ } catch (Throwable t) {
+ super.close(); // gated on headerPtr != 0, so it is safe and idempotent
+ throw t;
+ }
}
public void await() {
@@ -865,8 +934,26 @@ public void await() {
public void await(int timeout) {
int totalBytesReceived = 0;
long unprocessedLo = responseParserBufLo;
+ // A positive timeout bounds the whole call, not each socket read - the same rule
+ // AbstractResponse.recv and AbstractChunkedResponse.recv apply to the BODY, and for the same
+ // reason. recvOrDie returns 0 whenever a read produced no application bytes (an incomplete TLS
+ // record that decrypts to nothing is the common case, and the IDP endpoints are required to be
+ // https), and a 0 leaves totalBytesReceived unmoved, so the loop neither advances the header
+ // parser nor fills its buffer: without one shared deadline it re-arms the full timeout forever
+ // and never reaches the "header is too large" escape either. That put no bound at all on
+ // OidcDeviceAuth's postForm/fetchJson, which read this head from an untrusted identity provider
+ // on the getToken() flush path. A non-positive timeout keeps the legacy "no bound" behaviour.
+ final boolean bounded = timeout > 0;
+ final long startNanos = bounded ? System.nanoTime() : 0L;
while (isIncomplete()) {
- final int len = recvOrDie(responseParserBufLo + totalBytesReceived, timeout);
+ int callTimeout = timeout;
+ if (bounded) {
+ callTimeout = timeout - (int) ((System.nanoTime() - startNanos) / 1_000_000L);
+ if (callTimeout <= 0) {
+ throw new HttpClientException("timed out reading the response head");
+ }
+ }
+ final int len = recvOrDie(responseParserBufLo + totalBytesReceived, callTimeout);
if (len > 0) {
totalBytesReceived += len;
unprocessedLo = parse(unprocessedLo, responseParserBufLo + totalBytesReceived, false, true);
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientLinux.java b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientLinux.java
index 472b5257a..198cbf965 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientLinux.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientLinux.java
@@ -36,10 +36,18 @@ public class HttpClientLinux extends HttpClient {
public HttpClientLinux(HttpClientConfiguration configuration, SocketFactory socketFactory) {
super(configuration, socketFactory);
- epoll = new Epoll(
- configuration.getEpollFacade(),
- configuration.getWaitQueueCapacity()
- );
+ // The base constructor already took a socket and two native buffers. If epoll_create fails here -
+ // fd exhaustion is exactly when it does - this object never reaches the caller, so nothing ever
+ // closes it and those stay lost. Roll the base back before rethrowing.
+ try {
+ epoll = new Epoll(
+ configuration.getEpollFacade(),
+ configuration.getWaitQueueCapacity()
+ );
+ } catch (Throwable t) {
+ super.close();
+ throw t;
+ }
}
@Override
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientOsx.java b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientOsx.java
index aae49dc3e..980fe9da5 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientOsx.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientOsx.java
@@ -35,10 +35,17 @@ public class HttpClientOsx extends HttpClient {
public HttpClientOsx(HttpClientConfiguration configuration, SocketFactory socketFactory) {
super(configuration, socketFactory);
- this.kqueue = new Kqueue(
- configuration.getKQueueFacade(),
- configuration.getWaitQueueCapacity()
- );
+ // See HttpClientLinux: a kqueue() failure here would strand the socket and native buffers the base
+ // constructor already took, on an object nobody can close.
+ try {
+ this.kqueue = new Kqueue(
+ configuration.getKQueueFacade(),
+ configuration.getWaitQueueCapacity()
+ );
+ } catch (Throwable t) {
+ super.close();
+ throw t;
+ }
}
@Override
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientWindows.java b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientWindows.java
index 62ee43fe3..e1153f552 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientWindows.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientWindows.java
@@ -37,8 +37,20 @@ public class HttpClientWindows extends HttpClient {
public HttpClientWindows(HttpClientConfiguration configuration, SocketFactory socketFactory) {
super(configuration, socketFactory);
- this.fdSet = new FDSet(configuration.getWaitQueueCapacity());
- this.sf = configuration.getSelectFacade();
+ // See HttpClientLinux: an allocation failure here would strand the socket and native buffers the
+ // base constructor already took, on an object nobody can close.
+ // getSelectFacade() is inside the guard, not after it: the shipped default cannot throw, but this
+ // takes a caller-supplied HttpClientConfiguration, and an override that does would have stranded the
+ // FDSet as well as everything the base constructor took. Linux and Osx evaluate every configuration
+ // getter inside their guard already; this was the odd one out.
+ try {
+ this.fdSet = new FDSet(configuration.getWaitQueueCapacity());
+ this.sf = configuration.getSelectFacade();
+ } catch (Throwable t) {
+ this.fdSet = Misc.free(fdSet); // null when FDSet itself threw; Misc.free tolerates that
+ super.close();
+ throw t;
+ }
}
@Override
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java b/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java
index 166a7a28c..0c7845fc8 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java
@@ -29,9 +29,48 @@
*/
public interface Response {
/**
- * Receives the next fragment of response data using the default timeout.
+ * Receives the next fragment of response data, bounded by this response's default timeout - the
+ * {@link io.questdb.client.HttpClientConfiguration#getTimeout()} of the client that produced it.
+ *
+ * The bound {@link #recv(int)} describes applies here too, because the implementations in this library
+ * implement this method as {@code recv(defaultTimeout)}: it caps the WHOLE call rather than each socket
+ * read, so a server dribbling the body cannot keep one call running past it. Every configuration this
+ * library builds supplies a positive timeout - {@code request_timeout} is rejected below 1 on both the
+ * builder and the configuration-string paths - so the bound is live unless a caller supplies its own
+ * {@code HttpClientConfiguration} returning a non-positive value, which disables it.
+ *
+ * Size that timeout against a SINGLE fragment read rather than the whole body: each call starts its own
+ * budget, so a large body spread over many calls is unaffected, and only one call that cannot complete
+ * within the timeout aborts.
+ *
+ * Note the two methods delegate in OPPOSITE directions, which is what decides whether the bound exists
+ * at all. The {@link #recv(int)} default defers down to this method and discards its argument; the
+ * implementations here do the reverse. So an implementation overriding only this method is unbounded on
+ * both, while one extending {@code AbstractResponse} or {@code AbstractChunkedResponse} is bounded on
+ * both.
*
- * @return the received fragment
+ * @return the received fragment, or null once the body has been fully read
*/
Fragment recv();
+
+ /**
+ * Receives the next fragment of response data. A positive {@code timeout} bounds the whole call to that
+ * many milliseconds in total (not per socket read), so a server dribbling the body one byte at a time
+ * cannot keep a single call running past it; a non-positive {@code timeout} disables the bound.
+ *
+ * Defaulted rather than abstract for compatibility: this interface is exported, ships with a javadoc
+ * jar, and gained {@code recv(int)} after {@link #recv()}, so an implementation written against the
+ * earlier interface must keep both compiling and linking. The default ignores the bound and defers to
+ * {@link #recv()} -- precisely what such an implementation did before this overload existed.
+ *
+ * Every implementation in this library overrides it, and any implementation that wants the bound
+ * honoured must do the same. An overriding implementation must not then implement {@link #recv()} by
+ * calling back into this default, which would recurse.
+ *
+ * @param timeout the receive timeout in milliseconds
+ * @return the received fragment, or null once the body has been fully read
+ */
+ default Fragment recv(int timeout) {
+ return recv();
+ }
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java b/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java
index 565a02344..cbb0e38cd 100644
--- a/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java
+++ b/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java
@@ -55,10 +55,12 @@ public class JsonLexer implements Mutable, Closeable {
private final int cacheSizeLimit;
private final IntStack objDepthStack = new IntStack(64);
private final StringSink sink = new StringSink();
+ private final StringSink unescapeSink = new StringSink();
private int arrayDepth = 0;
private long cache;
private int cacheCapacity;
private int cacheSize = 0;
+ private boolean hasEscape = false;
private boolean ignoreNext = false;
private int objDepth = 0;
private int position = 0;
@@ -85,6 +87,7 @@ public void clear() {
arrayDepth = 0;
ignoreNext = false;
quoted = false;
+ hasEscape = false;
cacheSize = 0;
useCache = false;
position = 0;
@@ -109,6 +112,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException {
int state = this.state;
boolean quoted = this.quoted;
boolean ignoreNext = this.ignoreNext;
+ boolean hasEscape = this.hasEscape;
boolean useCache = this.useCache;
int objDepth = this.objDepth;
int arrayDepth = this.arrayDepth;
@@ -125,6 +129,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException {
if (quoted) {
if (c == '\\') {
ignoreNext = true;
+ hasEscape = true;
continue;
}
@@ -137,10 +142,10 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException {
int vp = (int) (posAtStart + valueStart - lo + 1 - cacheSize);
if (state == S_EXPECT_NAME || state == S_EXPECT_FIRST_NAME) {
- listener.onEvent(EVT_NAME, getCharSequence(valueStart, p, vp), vp);
+ listener.onEvent(EVT_NAME, getCharSequence(valueStart, p, vp, hasEscape), vp);
state = S_EXPECT_COLON;
} else {
- listener.onEvent(arrayDepth > 0 ? EVT_ARRAY_VALUE : EVT_VALUE, getCharSequence(valueStart, p, vp), vp);
+ listener.onEvent(arrayDepth > 0 ? EVT_ARRAY_VALUE : EVT_VALUE, getCharSequence(valueStart, p, vp, hasEscape), vp);
state = S_EXPECT_COMMA;
}
@@ -240,6 +245,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException {
}
valueStart = p;
quoted = true;
+ hasEscape = false;
break;
default:
if (state != S_EXPECT_VALUE) {
@@ -248,6 +254,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException {
// this isn't a quote, include this character
valueStart = p - 1;
quoted = false;
+ hasEscape = false;
break;
}
}
@@ -257,6 +264,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException {
this.state = state;
this.quoted = quoted;
this.ignoreNext = ignoreNext;
+ this.hasEscape = hasEscape;
this.objDepth = objDepth;
this.arrayDepth = arrayDepth;
@@ -282,10 +290,46 @@ public void parseLast() throws JsonException {
}
}
+ /**
+ * Overwrites the decode buffers, so a secret this lexer parsed is no longer legible through them.
+ *
+ * Every name and value the lexer emits is assembled in {@link #sink} first, and an escaped one is
+ * then resolved into {@link #unescapeSink}; a listener that copies the value out leaves the lexer's
+ * own copy behind. {@link #clear()} does not help - it rewinds the parse state and never touches
+ * either sink, and {@link StringSink#clear()} would only rewind the write position anyway, leaving
+ * a long secret legible in the tail past a shorter later write. {@link #close()} frees the native
+ * cache without zeroing it, and neither sink is reachable from outside this class.
+ *
+ * Callers that parse credentials should wipe rather than clear between documents - {@code
+ * OidcDeviceAuth} parses the token endpoint's response with a long-lived lexer, so its access, id
+ * and refresh tokens would otherwise stay on the heap for the life of that instance. Like
+ * {@link StringSink#wipe()} this is best effort: it reaches this lexer's own storage, not a copy a
+ * listener has already taken.
+ */
+ public void wipe() {
+ sink.wipe();
+ unescapeSink.wipe();
+ }
+
private static boolean isNotATerminator(char c) {
return unquotedTerminators.excludes(c);
}
+ private static int parseHex4(CharSequence value, int offset) {
+ int result = 0;
+ for (int j = 0; j < 4; j++) {
+ final char c = value.charAt(offset + j);
+ // shared hex table lookup (-1 for non-hex), cheaper than Character.digit; the table is
+ // ASCII-sized, so a code point above 127 is never a hex digit
+ final int digit = c < 128 ? Numbers.hexNumbers[c] : -1;
+ if (digit < 0) {
+ return -1;
+ }
+ result = (result << 4) | digit;
+ }
+ return result;
+ }
+
private static JsonException unsupportedEncoding(int position) {
return JsonException.$(position, "Unsupported encoding");
}
@@ -319,7 +363,7 @@ private void extendCache(int n) throws JsonException {
cache = ptr;
}
- private CharSequence getCharSequence(long lo, long hi, int position) throws JsonException {
+ private CharSequence getCharSequence(long lo, long hi, int position, boolean hasEscape) throws JsonException {
sink.clear();
if (cacheSize == 0) {
if (!Utf8s.utf8ToUtf16(lo, hi - 1, sink)) {
@@ -328,7 +372,81 @@ private CharSequence getCharSequence(long lo, long hi, int position) throws Json
} else {
utf8DecodeCacheAndBuffer(lo, hi - 1, position);
}
- return sink;
+ // the decode above assembled the raw bytes verbatim; resolve JSON escapes only when the scan saw a
+ // backslash, so escape-free values and names skip unescape() and return the assembled sink directly.
+ return hasEscape ? unescape(sink) : sink;
+ }
+
+ private CharSequence unescape(CharSequence raw) {
+ // called only when the scan saw a backslash, so at least one escape is present; walk the value once,
+ // copying plain chars and resolving each escape - no leading scan to re-find the first backslash.
+ final int n = raw.length();
+ unescapeSink.clear();
+ int i = 0;
+ while (i < n) {
+ char c = raw.charAt(i);
+ if (c != '\\' || i + 1 >= n) {
+ unescapeSink.put(c);
+ i++;
+ continue;
+ }
+ char esc = raw.charAt(i + 1);
+ switch (esc) {
+ case '"':
+ unescapeSink.put('"');
+ i += 2;
+ break;
+ case '\\':
+ unescapeSink.put('\\');
+ i += 2;
+ break;
+ case '/':
+ unescapeSink.put('/');
+ i += 2;
+ break;
+ case 'b':
+ unescapeSink.put('\b');
+ i += 2;
+ break;
+ case 'f':
+ unescapeSink.put('\f');
+ i += 2;
+ break;
+ case 'n':
+ unescapeSink.put('\n');
+ i += 2;
+ break;
+ case 'r':
+ unescapeSink.put('\r');
+ i += 2;
+ break;
+ case 't':
+ unescapeSink.put('\t');
+ i += 2;
+ break;
+ case 'u':
+ int cp = i + 6 <= n ? parseHex4(raw, i + 2) : -1;
+ if (cp >= 0) {
+ unescapeSink.put((char) cp);
+ i += 6;
+ } else {
+ // malformed unicode escape: keep the backslash and the 'u' verbatim (lenient), so a
+ // non-conformant server's literal text survives rather than silently losing a byte
+ unescapeSink.put('\\').put(esc);
+ i += 2;
+ }
+ break;
+ default:
+ // an unrecognized escape letter: keep the backslash and the char verbatim (lenient), so a
+ // stray '\' before a non-escape char in non-conformant input survives rather than being
+ // dropped. A '\' before a RECOGNIZED escape letter (" \ / b f n r t u) is still decoded by
+ // the cases above - standard JSON unescape - so only genuinely unknown sequences reach here.
+ unescapeSink.put('\\').put(esc);
+ i += 2;
+ break;
+ }
+ }
+ return unescapeSink;
}
private void utf8DecodeCacheAndBuffer(long lo, long hi, int position) throws JsonException {
diff --git a/core/src/main/java/io/questdb/client/cutlass/json/JsonParser.java b/core/src/main/java/io/questdb/client/cutlass/json/JsonParser.java
index a4d6c45da..174b78ef0 100644
--- a/core/src/main/java/io/questdb/client/cutlass/json/JsonParser.java
+++ b/core/src/main/java/io/questdb/client/cutlass/json/JsonParser.java
@@ -24,7 +24,39 @@
package io.questdb.client.cutlass.json;
+/**
+ * Receives the events {@link JsonLexer} emits as it parses. Implementations assemble whatever they need
+ * from the event stream; the lexer keeps no document.
+ */
@FunctionalInterface
public interface JsonParser {
+ /**
+ * Called once per parse event, on the thread driving {@link JsonLexer#parse}.
+ *
+ *
{@code tag} is JSON-UNESCAPED. A value written {@code "a\\nb"} in the document arrives as
+ * the four characters {@code a \ n b}, not as the five raw ones. An implementation must NOT unescape it
+ * again: doing so decodes the {@code \n} a second time and yields {@code a}, LF, {@code b}. Earlier
+ * releases handed back the raw bytes and left the decoding to the listener, so a parser carried over
+ * from one of those has exactly that second decode to remove.
+ *
+ *
{@code tag} is a reused buffer, and not necessarily the same instance twice. It is valid
+ * only for the duration of this call - copy it to keep it. The lexer assembles an escape-free value in
+ * one sink and an escaped one in another, so which object arrives depends on whether that particular
+ * value contained a backslash. An implementation must therefore never compare {@code tag} by identity
+ * or cache the reference: either works across escape-free input and then fails on the first value that
+ * carries an escape.
+ *
+ *
{@code tag} is {@code null} for the structural events - {@link JsonLexer#EVT_OBJ_START},
+ * {@link JsonLexer#EVT_OBJ_END}, {@link JsonLexer#EVT_ARRAY_START} and {@link JsonLexer#EVT_ARRAY_END}
+ * - and non-null only for {@link JsonLexer#EVT_NAME}, {@link JsonLexer#EVT_VALUE} and
+ * {@link JsonLexer#EVT_ARRAY_VALUE}.
+ *
+ * @param code the event, one of {@code JsonLexer.EVT_*}
+ * @param tag the name or value the event carries, unescaped, or {@code null} for a structural
+ * event; borrowed for the duration of the call only
+ * @param position byte offset of the event within the whole parsed stream, accumulated across
+ * {@link JsonLexer#parse} calls rather than an index into {@code tag}
+ * @throws JsonException to abort the parse
+ */
void onEvent(int code, CharSequence tag, int position) throws JsonException;
}
\ No newline at end of file
diff --git a/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java b/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java
index b599efcdb..9bef98277 100644
--- a/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java
+++ b/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java
@@ -48,24 +48,48 @@
public class LineSenderException extends RuntimeException {
private final StringSink message = new StringSink();
+ private final boolean retryable;
private int errno = Integer.MIN_VALUE;
public LineSenderException(CharSequence message) {
this.message.put(message);
+ this.retryable = false;
}
public LineSenderException(CharSequence message, boolean retryable) {
this.message.put(message);
+ this.retryable = retryable;
}
public LineSenderException(Throwable t) {
super(t);
+ this.retryable = false;
}
public LineSenderException(String message, Throwable cause) {
super(message, cause);
this.message.put(message);
+ this.retryable = false;
+ }
+
+ /**
+ * Whether the sender classified this failure as worth retrying - a 5xx, a 429, a transport error - as
+ * opposed to one that will keep failing, such as a 401 or a malformed request.
+ *
+ * This is the flag the class documentation above tells a caller to act on: a transient error means call
+ * {@code flush()} again on the same sender, a permanent one means close it or {@code reset()}. It was
+ * accepted by the {@link #LineSenderException(CharSequence, boolean)} constructor and then discarded,
+ * so the sender computed the answer and no caller could read it.
+ *
+ * {@code false} means "not classified as retryable", not "proven permanent": the constructors that carry
+ * no classification - a bare message, a wrapped cause - report {@code false}, which is the conservative
+ * direction for a caller that retries only on {@code true}.
+ *
+ * @return true when the sender classified this failure as retryable
+ */
+ public boolean isRetryable() {
+ return retryable;
}
public LineSenderException appendIPv4(int ip) {
diff --git a/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java b/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java
index 398aa70a1..768b37916 100644
--- a/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java
+++ b/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java
@@ -27,9 +27,11 @@
import io.questdb.client.BuildInformationHolder;
import io.questdb.client.ClientTlsConfiguration;
import io.questdb.client.HttpClientConfiguration;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.Sender;
import io.questdb.client.cairo.TableUtils;
import io.questdb.client.cutlass.http.HttpConstants;
+import io.questdb.client.cutlass.http.HttpException;
import io.questdb.client.cutlass.http.HttpKeywords;
import io.questdb.client.cutlass.http.client.Fragment;
import io.questdb.client.cutlass.http.client.HttpClient;
@@ -57,10 +59,13 @@
import io.questdb.client.std.str.Utf8Sequence;
import io.questdb.client.std.str.Utf8s;
import org.jetbrains.annotations.TestOnly;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.io.Closeable;
public abstract class AbstractLineHttpSender implements Sender {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractLineHttpSender.class);
private static final String PATH = "/write?precision=n";
private static final int RETRY_BACKOFF_MULTIPLIER = 2;
private static final int RETRY_INITIAL_BACKOFF_MS = 10;
@@ -82,12 +87,15 @@ public abstract class AbstractLineHttpSender implements Sender {
private final CharSequence questDBVersion;
private final Rnd rnd;
private final StringSink sink = new StringSink();
+ private final String userAgent;
private final String username;
protected HttpClient.Request request;
private HttpClient client;
private boolean closed;
private int currentAddressIndex;
private long flushAfterNanos = Long.MAX_VALUE;
+ private HttpTokenProvider httpTokenProvider;
+ private boolean isTokenPending;
private JsonErrorParser jsonErrorParser;
private boolean lastFlushFailed;
private long pendingRows;
@@ -200,6 +208,9 @@ protected AbstractLineHttpSender(
: HttpClientFactory.newPlainTextInstance(clientConfiguration);
}
this.questDBVersion = new BuildInformationHolder().getSwVersion();
+ // precompute the User-Agent header value once: newRequest() runs on every flush, so concatenating it
+ // there would allocate a String each time
+ this.userAgent = "QuestDB/java/" + questDBVersion;
this.request = newRequest();
this.maxNameLength = maxNameLength;
this.rnd = rnd;
@@ -225,10 +236,17 @@ public static AbstractLineHttpSender createLineSender(
) {
return createLineSender(new ObjList<>(host), IntList.createWithValues(port), path, clientConfiguration, tlsConfig, autoFlushRows, authToken, username, password, maxNameLength, maxRetriesNanos, maxBackoffMillis, minRequestThroughput,
flushIntervalNanos,
- protocolVersion
+ protocolVersion,
+ null
);
}
+ /**
+ * Provider-less form of the overload below, kept so callers compiled against the pre-{@code
+ * httpTokenProvider} signature keep linking. Mirrors the single-host overload above, which delegates
+ * with the same {@code null} provider.
+ */
+ @SuppressWarnings("unused")
public static AbstractLineHttpSender createLineSender(
ObjList hosts,
IntList ports,
@@ -245,6 +263,29 @@ public static AbstractLineHttpSender createLineSender(
long minRequestThroughput,
long flushIntervalNanos,
int protocolVersion
+ ) {
+ return createLineSender(hosts, ports, path, clientConfiguration, tlsConfig, autoFlushRows,
+ authToken, username, password, maxNameLength, maxRetriesNanos, maxBackoffMillis,
+ minRequestThroughput, flushIntervalNanos, protocolVersion, null);
+ }
+
+ public static AbstractLineHttpSender createLineSender(
+ ObjList hosts,
+ IntList ports,
+ String path,
+ HttpClientConfiguration clientConfiguration,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ String authToken,
+ String username,
+ String password,
+ int maxNameLength,
+ long maxRetriesNanos,
+ int maxBackoffMillis,
+ long minRequestThroughput,
+ long flushIntervalNanos,
+ int protocolVersion,
+ HttpTokenProvider httpTokenProvider
) {
HttpClient cli = null;
Rnd rnd = new Rnd(NanosecondClockImpl.INSTANCE.getTicks(), MicrosecondClockImpl.INSTANCE.getTicks());
@@ -297,7 +338,9 @@ public static AbstractLineHttpSender createLineSender(
} else {
lastErrorSink.clear();
}
- chunkedResponseToSink(response, lastErrorSink);
+ // the construct-time probe retries on any read abort (caught below), so its own
+ // configured request timeout is the right bound here
+ chunkedResponseToSink(response, lastErrorSink, clientConfiguration.getTimeout());
} catch (HttpClientException e) {
if (lastErrorSink == null) {
lastErrorSink = new StringSink();
@@ -329,14 +372,17 @@ public static AbstractLineHttpSender createLineSender(
if (protocolVersion == PROTOCOL_VERSION_NOT_SET_EXPLICIT) {
Misc.free(cli);
if (lastErrorSink != null) {
- throw new LineSenderException("Failed to detect server line protocol version: " + lastErrorSink);
+ // sanitize the raw server body before it reaches the exception message (and any log/terminal):
+ // a hostile or proxied endpoint must not splice control, ANSI or bidi chars into the render
+ throw new LineSenderException("Failed to detect server line protocol version: ").putAsPrintable(lastErrorSink);
}
throw new LineSenderException("Failed to detect server line protocol version");
}
+ final AbstractLineHttpSender sender;
switch (protocolVersion) {
case PROTOCOL_VERSION_V1:
- return new LineHttpSenderV1(
+ sender = new LineHttpSenderV1(
hosts,
ports,
path,
@@ -355,8 +401,9 @@ public static AbstractLineHttpSender createLineSender(
currentAddressIndex,
rnd
);
+ break;
case PROTOCOL_VERSION_V2:
- return new LineHttpSenderV2(
+ sender = new LineHttpSenderV2(
hosts,
ports,
path,
@@ -375,8 +422,9 @@ public static AbstractLineHttpSender createLineSender(
currentAddressIndex,
rnd
);
+ break;
case PROTOCOL_VERSION_V3:
- return new LineHttpSenderV3(
+ sender = new LineHttpSenderV3(
hosts,
ports,
path,
@@ -395,9 +443,22 @@ public static AbstractLineHttpSender createLineSender(
currentAddressIndex,
rnd
);
+ break;
default:
throw new LineSenderException("Unsupported protocol version: " + protocolVersion);
}
+ if (httpTokenProvider != null) {
+ // The constructor built the initial request before the provider was wired (httpTokenProvider was
+ // still null, so it took the no-auth path with withContent). Rebuild it via the deferred path now
+ // that the provider is set: this leaves the request at the header stage with the token pending,
+ // matching the reset() path, so the first row's stampTokenIfPending() finishes it (appends the auth
+ // header + withContent()) without a second client.newRequest(). Deferring the first getToken() off
+ // the build path also lets a lazily-signing-in provider (e.g. OidcDeviceAuth::getToken) be wired
+ // before sign-in completes, keeping the token pull on the use/flush path the provider documents.
+ sender.httpTokenProvider = httpTokenProvider;
+ sender.request = sender.newRequest();
+ }
+ return sender;
}
public static boolean isNotFound(DirectUtf8Sequence statusCode) {
@@ -409,20 +470,10 @@ public static boolean isNotFound(DirectUtf8Sequence statusCode) {
@Override
public void atNow() {
- switch (state) {
- case EMPTY:
- throw new LineSenderException("no table name was provided");
- case TABLE_NAME_SET:
- throw new LineSenderException("no symbols or columns were provided");
- case ADDING_SYMBOLS:
- case ADDING_COLUMNS:
- request.put('\n');
- state = RequestState.EMPTY;
- break;
- }
- if (rowAdded()) {
- flush();
- }
+ // validateRowStarted() rejects EMPTY and TABLE_NAME_SET, so only ADDING_SYMBOLS and ADDING_COLUMNS
+ // reach the terminator write
+ validateRowStarted();
+ terminateRow();
}
@Override
@@ -439,6 +490,12 @@ public DirectByteSlice bufferView() {
@Override
public void cancelRow() {
validateNotClosed();
+ // While isTokenPending, newRequest() has left the request at the header stage: withContent() has not
+ // run, contentStart is still the -1 sentinel, and no row bytes exist to trim. trimContentToLen is
+ // guarded against exactly that state and no-ops, so this needs no second guard of its own - one that
+ // could never be observed to be missing, since the other one masks it. The guard that survives is
+ // the one that protects every caller of an exported method, not just this one; it is pinned by
+ // HttpClientRequestTrimTest. Do not re-add a check here: add coverage there instead.
request.trimContentToLen(rowBookmark);
state = RequestState.EMPTY;
}
@@ -455,7 +512,7 @@ public void close() {
flush0(true);
}
} finally {
- Misc.free(jsonErrorParser);
+ jsonErrorParser = Misc.free(jsonErrorParser);
closed = true;
client = Misc.free(client);
}
@@ -483,6 +540,9 @@ public Sender longColumn(CharSequence name, long value) {
@TestOnly
public void putRawMessage(Utf8Sequence msg) {
+ // stamp the deferred provider token (like table() does) so a raw message sent as the first row
+ // carries it; a no-op when no provider is configured
+ stampTokenIfPending();
request.put(msg); // message must include trailing \n
state = RequestState.EMPTY;
if (rowAdded()) {
@@ -539,6 +599,9 @@ public Sender table(CharSequence table) {
if (table.length() == 0) {
throw new LineSenderException("table name cannot be empty");
}
+ // stamp the deferred provider token before the first row of this request, so the send carries it;
+ // a no-op once the token has been stamped or when no provider is configured
+ stampTokenIfPending();
// set bookmark at start of the line.
rowBookmark = request.getContentLength();
state = RequestState.TABLE_NAME_SET;
@@ -553,13 +616,13 @@ private static int backoff(Rnd rnd, int retryBackoff, int retryMaxBackoffMs) {
return Math.min(retryMaxBackoffMs, backoff * RETRY_BACKOFF_MULTIPLIER);
}
- private static void chunkedResponseToSink(HttpClient.ResponseHeaders response, StringSink sink) {
+ private static void chunkedResponseToSink(HttpClient.ResponseHeaders response, StringSink sink, int timeoutMillis) {
if (!response.isChunked()) {
return;
}
Response chunkedRsp = response.getResponse();
Fragment fragment;
- while ((fragment = chunkedRsp.recv()) != null) {
+ while ((fragment = chunkedRsp.recv(timeoutMillis)) != null) {
sink.putNonAscii(fragment.lo(), fragment.hi());
}
}
@@ -600,13 +663,13 @@ private static boolean keepAliveDisabled(HttpClient.ResponseHeaders response) {
return HttpKeywords.isClose(connectionHeader);
}
- private void consumeChunkedResponse(HttpClient.ResponseHeaders response) {
+ private void consumeChunkedResponse(HttpClient.ResponseHeaders response, int timeoutMillis) {
if (!response.isChunked()) {
return;
}
Response chunkedRsp = response.getResponse();
//noinspection StatementWithEmptyBody
- while ((chunkedRsp.recv()) != null) {
+ while ((chunkedRsp.recv(timeoutMillis)) != null) {
// we don't care about the response, just consume it, so it won't stay in the socket receive buffer
}
}
@@ -671,9 +734,37 @@ private void flush0(boolean closing) {
response.await(remainingMillis);
DirectUtf8Sequence statusCode = response.getStatusCode();
if (isSuccessResponse(statusCode)) {
- consumeChunkedResponse(response); // if any
- if (keepAliveDisabled(response)) {
- // Server has HTTP keep-alive disabled, and it's closing this TCP connection.
+ // pass the whole per-flush budget (base + throughput extension) as EACH recv() read's
+ // timeout, NOT the raw request_timeout: recv() otherwise inherits defaultTimeout, so a
+ // tuned-low request_timeout paired with request_min_throughput would abort a large,
+ // still-progressing chunked body. This bounds each read, not the whole body cumulatively -
+ // fine here because the ILP server is trusted (unlike OidcDeviceAuth.parseBody, which also
+ // caps total bytes and elapsed time against an untrusted identity provider).
+ // A 2xx IS the commit: the server already has these rows. Draining its response body
+ // afterwards is only bookkeeping to keep the connection reusable, so a failure there must
+ // not escape into the catch below, which treats HttpClientException as a transport error
+ // and re-sends the whole batch -- duplicate rows on data the server accepted. Base could
+ // not reach this, because recv() re-armed its timeout on every socket read and a
+ // dribbling-but-progressing body never aborted; bounding the whole call means it now can.
+ // On abort the body is left unconsumed, which would mis-frame the next response on this
+ // connection, so drop the connection and report the flush as what it was: a success.
+ boolean drained = true;
+ try {
+ consumeChunkedResponse(response, actualTimeoutMillis); // if any
+ } catch (HttpClientException e) {
+ // The flush already SUCCEEDED - a 2xx IS the commit - so this changes no outcome,
+ // only the connection: unconsumed bytes would mis-frame the next response on it, so
+ // it is dropped below and the next flush reconnects. That cost is otherwise
+ // invisible - a server or intermediary that dribbles every response turns into one
+ // reconnect per flush, and the only symptom is churn nothing explains. DEBUG, not
+ // WARN: the handling is correct and a legitimately slow body is not a fault.
+ drained = false;
+ LOG.debug("could not drain the response body after a successful flush; dropping the "
+ + "connection so the next response cannot be mis-framed [reason={}]",
+ e.getMessage());
+ }
+ // Server has HTTP keep-alive disabled, and it's closing this TCP connection.
+ if (!drained || keepAliveDisabled(response)) {
client.disconnect();
}
lastFlushFailed = false;
@@ -692,15 +783,46 @@ private void flush0(boolean closing) {
: retryingDeadlineNanos;
if (nowNanos >= retryingDeadlineNanos) {
// throw, but do not reset - a caller can try to flush later
- throwOnHttpErrorResponse(statusCode, response, true);
+ throwOnHttpErrorResponse(statusCode, response, true, actualTimeoutMillis);
}
client.disconnect(); // forces reconnect, just in case
retryBackoff = backoff(rnd, retryBackoff, maxBackoffMillis);
continue;
}
- throwOnHttpErrorResponse(statusCode, response, false);
+ throwOnHttpErrorResponse(statusCode, response, false, actualTimeoutMillis);
+ } catch (HttpException e) {
+ // An unparseable response head: response.await() above hands it to HttpHeaderParser, which
+ // rejects a header block past its fixed 4096-byte buffer (an intermediary stacking
+ // Set-Cookie/CSP), a malformed Content-Length, or a status line that is not HTTP/1.x.
+ // HttpException is a SIBLING of HttpClientException, not a subclass, so it used to escape
+ // both arms - taking with it the client.disconnect() that keeps the next flush off a
+ // connection holding a half-read response, and leaving flush() throwing a raw
+ // HttpException rather than the LineSenderException its contract promises.
+ //
+ // Handled here rather than in the retry arm below, because it is not a transport failure
+ // and must not be retried. HttpHeaderParser only runs on bytes that ARRIVED, so this is
+ // positive evidence the server answered - the same evidence the 2xx drain arm above treats
+ // as decisive - and the head is chosen by an intermediary, not by chance: the next attempt
+ // parses the same block and fails identically. Retrying spent the whole budget re-sending a
+ // batch the server had already taken (measured: 16 sends over ~11s per flush at the default
+ // budget, against 1 before HttpException reached the arm), which for a table without DEDUP
+ // keys is 15 extra copies of every row.
+ //
+ // Disconnect anyway: the half-read response would mis-frame the next one on this
+ // connection. lastFlushFailed suppresses the close-time re-flush for the same reason - the
+ // server already has these rows.
+ lastFlushFailed = true;
+ client.disconnect();
+ LineSenderException headEx = new LineSenderException("Could not flush buffer: http");
+ if (isTls) {
+ headEx.put('s');
+ }
+ headEx.put("://");
+ headEx.put(currentHost()).put(':').put(currentPort()).put(this.path);
+ headEx.put(" Malformed HTTP response head").put(": ").put(e.getMessage());
+ throw headEx;
} catch (HttpClientException e) {
- // this is a network error, we can retry
+ // this is a network error, we can retry.
lastFlushFailed = true;
client.disconnect(); // forces reconnect
long nowNanos = System.nanoTime();
@@ -730,9 +852,21 @@ private HttpClient.Request newRequest() {
HttpClient.Request r = client.newRequest(currentHost(), currentPort())
.POST()
.url(path)
- .header("User-Agent", "QuestDB/java/" + questDBVersion);
+ .header("User-Agent", userAgent);
if (username != null) {
r.authBasic(username, password);
+ } else if (httpTokenProvider != null) {
+ // Do NOT pull the token here (the construct / flush-completion path): getToken() can throw (not
+ // signed in yet, or a failed silent refresh), and a throw after client.newRequest() reset the
+ // shared request would corrupt the sender, turning an already-successful flush into an exception.
+ // Leave the request at the header stage (no withContent() yet) with the token pending, so the first
+ // row's stampTokenIfPending() appends the Authorization header + withContent() on THIS request
+ // WITHOUT a second client.newRequest() - the request line and headers are written once per flush,
+ // not twice. bufferView() reads empty meanwhile (contentStart is -1, so getContentLength() is 0).
+ isTokenPending = true;
+ rowBookmark = r.getContentLength();
+ state = RequestState.EMPTY;
+ return r;
} else if (authToken != null) {
r.authToken(authToken);
}
@@ -766,21 +900,98 @@ private boolean rowAdded() {
return pendingRows == autoFlushRows;
}
- private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable) {
+ private void stampTokenIfPending() {
+ if (isTokenPending) {
+ // The construct/flush path deferred the token so a lazily-signing-in provider (e.g.
+ // OidcDeviceAuth::getToken) could be wired before sign-in completed, and so a provider failure
+ // never strikes after a successful send. The caller is now starting the first row, so finish the
+ // request newRequest() left at the header stage: pull a fresh token (so a long-lived sender
+ // follows token rotation), then append the Authorization header + withContent() on THIS request -
+ // no second client.newRequest(), so the request line and headers are written once, not twice.
+ //
+ // The throwing operations run BEFORE the request is mutated: a getToken()/validateToken() throw
+ // (not signed in yet, a failed refresh, or a rejected token) leaves isTokenPending set and the
+ // request untouched at the header stage, so the next row retries cleanly - the sender is never left
+ // corrupted. Validate EVERY pulled token, not just a changed instance: HttpTokenProvider.getToken()
+ // makes no immutability promise, so a provider that reuses one CharSequence buffer (the idiomatic
+ // zero-alloc style) and mutates its content between flushes must be re-checked, or a mutated token
+ // could splice a CR/LF into the "Authorization: Bearer" header (request.authToken writes it verbatim,
+ // with no CR/LF filtering). The scan is O(token length) and is dwarfed by the flush's network
+ // round-trip; the WebSocket auth path validates on every pull for the same reason.
+ CharSequence pulled;
+ try {
+ pulled = httpTokenProvider.getToken();
+ } catch (LineSenderException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new LineSenderException(
+ e.getMessage() == null
+ ? "token provider failed to supply a credential"
+ : e.getMessage(),
+ e);
+ }
+ // Snapshot BEFORE validating, so the bytes that are checked are the bytes that are sent. Without
+ // it validateToken scans the provider's sequence and authToken then re-reads it - two reads of a
+ // buffer the provider owns and, per the paragraph above, is invited to reuse. A mutation landing
+ // between them passes the check and splices the mutated content, CR/LF included, into the
+ // Authorization header. One String per FLUSH (not per row), dwarfed by the round-trip that
+ // follows. Null-safe: a null pull must still reach validateToken's "null or empty" message
+ // rather than NPE here.
+ CharSequence token = pulled == null ? null : pulled.toString();
+ HttpTokenProvider.validateToken(token);
+ request.authToken(token);
+ request.withContent();
+ rowBookmark = request.getContentLength();
+ state = RequestState.EMPTY;
+ isTokenPending = false;
+ }
+ }
+
+ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable, int timeoutMillis) {
+ // The STATUS is the verdict; the body is detail for the message. A body read that aborts must not
+ // escape into flush0's catch, which treats HttpClientException as a transport failure: a definitive
+ // 401/403/405 would be reclassified as a network error, retried for the whole retry budget, and
+ // finally surfaced as "Connection Failed: timed out reading the chunked response body" with the real
+ // status nowhere in it. Report the status we already have instead, and say the body was unreadable
+ // rather than inventing detail. LineSenderException is a sibling of HttpClientException, not a
+ // subclass, so the intended throw passes through this catch untouched.
+ try {
+ throwOnHttpErrorResponse0(statusCode, response, retryable, timeoutMillis);
+ } catch (HttpClientException e) {
+ client.disconnect();
+ // Carry the reason across. The status is the verdict, but WHY the body could not be read is the
+ // actionable half, and the three shapes call for different responses: "timed out reading the
+ // chunked response body" points at the flush timeout, "peer disconnect [errno=54]" at the
+ // connection, "malformed chunk size" at an intermediary mangling the framing. Binding e and
+ // dropping it left an operator a status and no way to tell those apart, on a path that has
+ // already disconnected. Plain put, not putAsPrintable: HttpClientException's messages are
+ // client-authored constants plus an errno, so unlike the status beside them they carry no
+ // server-supplied bytes.
+ final String reason = e.getMessage();
+ throw new LineSenderException("Could not flush buffer: could not read the error response body", retryable)
+ .put(" [http-status=").putAsPrintable(statusCode.asAsciiCharSequence())
+ .put(", reason=").put(reason != null ? reason : "")
+ .put(']');
+ }
+ }
+
+ private void throwOnHttpErrorResponse0(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable, int timeoutMillis) {
CharSequence statusAscii = statusCode.asAsciiCharSequence();
if (Chars.equals("405", statusAscii)) {
- consumeChunkedResponse(response);
+ consumeChunkedResponse(response, timeoutMillis);
client.disconnect();
throw new LineSenderException("Could not flush buffer: HTTP endpoint does not support ILP. [http-status=405]", retryable);
}
if (Chars.equals("401", statusAscii) || Chars.equals("403", statusAscii)) {
sink.clear();
- chunkedResponseToSink(response, sink);
+ chunkedResponseToSink(response, sink, timeoutMillis);
LineSenderException ex = new LineSenderException("Could not flush buffer: HTTP endpoint authentication error", retryable);
if (sink.length() > 0) {
- ex = ex.put(": ").put(sink);
+ // sanitize the raw server body before it reaches the exception message (and any log/terminal):
+ // an untrusted or proxied endpoint must not splice control, ANSI or bidi chars into the render
+ ex = ex.put(": ").putAsPrintable(sink);
}
- ex.put(" [http-status=").put(statusAscii).put(']');
+ ex.put(" [http-status=").putAsPrintable(statusAscii).put(']');
client.disconnect();
throw ex;
}
@@ -790,17 +1001,20 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient.
jsonErrorParser = new JsonErrorParser();
}
jsonErrorParser.reset();
- LineSenderException ex = jsonErrorParser.toException(response.getResponse(), statusCode, retryable);
+ LineSenderException ex = jsonErrorParser.toException(response.getResponse(), statusCode, retryable, timeoutMillis);
client.disconnect();
throw ex;
}
// ok, no JSON, let's do something more generic
sink.clear();
- sink.put("Could not flush buffer: ");
- chunkedResponseToSink(response, sink);
- sink.put(" [http-status=").put(statusCode).put(']');
+ chunkedResponseToSink(response, sink, timeoutMillis);
+ // sanitize the raw server body before it reaches the exception message (and any log/terminal):
+ // an untrusted or proxied endpoint must not splice control, ANSI or bidi chars into the render
+ LineSenderException ex = new LineSenderException("Could not flush buffer: ", retryable)
+ .putAsPrintable(sink)
+ .put(" [http-status=").putAsPrintable(statusCode.asAsciiCharSequence()).put(']');
client.disconnect();
- throw new LineSenderException(sink, retryable);
+ throw ex;
}
private void validateNotClosed() {
@@ -858,6 +1072,45 @@ protected void validateColumnName(CharSequence name) {
}
}
+ /**
+ * Writes the row terminator and closes the row, WITHOUT re-checking that a row was started - the caller
+ * has already done it.
+ *
+ * {@link #at(long, java.time.temporal.ChronoUnit)} and {@link #at(java.time.Instant)} must validate
+ * before they write the timestamp, not after: a rejected row would otherwise leave a stray timestamp in
+ * the request buffer for the next row to inherit. They used to follow that write with {@code atNow()},
+ * which validated the very same state a second time - nothing between the two calls can change it, since
+ * only {@code table()}, a column write and this method touch {@code state} - so every explicit-timestamp
+ * row paid for a second switch on the hot ingestion path. They call this instead.
+ */
+ protected void terminateRow() {
+ request.put('\n');
+ state = RequestState.EMPTY;
+ if (rowAdded()) {
+ flush();
+ }
+ }
+
+ /**
+ * Rejects a row terminator that no row precedes. Subclasses MUST call this before writing the first byte
+ * of a terminator, not after: with an httpTokenProvider configured, newRequest() leaves the request at the
+ * header stage (withContent() deferred until the first row stamps the Authorization header), so a write
+ * that lands here while the state is EMPTY goes into the HTTP HEADER block, not the request body. Those
+ * bytes then start a line that folds the following "Authorization: Bearer ..." into the previous header
+ * (RFC 7230 obs-fold), and the request ships with no credential at all. cancelRow() cannot undo it either:
+ * trimContentToLen only rewinds within the content section.
+ */
+ protected void validateRowStarted() {
+ switch (state) {
+ case EMPTY:
+ throw new LineSenderException("no table name was provided");
+ case TABLE_NAME_SET:
+ throw new LineSenderException("no symbols or columns were provided");
+ default:
+ break;
+ }
+ }
+
protected HttpClient.Request writeFieldName(CharSequence name) {
validateColumnName(name);
switch (state) {
@@ -968,16 +1221,16 @@ public void onEvent(int code, CharSequence tag, int position) throws JsonExcepti
private void drainAndReset(LineSenderException sink, DirectUtf8Sequence httpStatus) {
assert state == State.INIT;
- sink.put(messageSink).put(" [http-status=").put(httpStatus.asAsciiCharSequence());
+ sink.putAsPrintable(messageSink).put(" [http-status=").putAsPrintable(httpStatus.asAsciiCharSequence());
if (codeSink.length() != 0 || errorIdSink.length() != 0 || lineSink.length() != 0) {
if (errorIdSink.length() != 0) {
- sink.put(", id: ").put(errorIdSink);
+ sink.put(", id: ").putAsPrintable(errorIdSink);
}
if (codeSink.length() != 0) {
- sink.put(", code: ").put(codeSink);
+ sink.put(", code: ").putAsPrintable(codeSink);
}
if (lineSink.length() != 0) {
- sink.put(", line: ").put(lineSink);
+ sink.put(", line: ").putAsPrintable(lineSink);
}
}
sink.put(']');
@@ -994,10 +1247,10 @@ private void reset() {
jsonSink.clear();
}
- LineSenderException toException(Response chunkedRsp, DirectUtf8Sequence httpStatus, boolean retryable) {
+ LineSenderException toException(Response chunkedRsp, DirectUtf8Sequence httpStatus, boolean retryable, int timeoutMillis) {
Fragment fragment;
LineSenderException exception = new LineSenderException("Could not flush buffer: ", retryable);
- while ((fragment = chunkedRsp.recv()) != null) {
+ while ((fragment = chunkedRsp.recv(timeoutMillis)) != null) {
try {
jsonSink.putNonAscii(fragment.lo(), fragment.hi());
lexer.parse(fragment.lo(), fragment.hi(), this);
@@ -1005,10 +1258,12 @@ LineSenderException toException(Response chunkedRsp, DirectUtf8Sequence httpStat
// we failed to parse JSON, but we still want to show the error message.
// if we cannot parse it then we show the whole response as is.
// let's make sure we have the whole message - there might be more chunks
- while ((fragment = chunkedRsp.recv()) != null) {
+ while ((fragment = chunkedRsp.recv(timeoutMillis)) != null) {
jsonSink.putNonAscii(fragment.lo(), fragment.hi());
}
- exception.put(jsonSink).put(" [http-status=").put(httpStatus.asAsciiCharSequence()).put(']');
+ // sanitize the raw server body before it reaches the exception message (and any log/terminal):
+ // an untrusted or proxied endpoint must not splice control, ANSI or bidi chars into the render
+ exception.putAsPrintable(jsonSink).put(" [http-status=").putAsPrintable(httpStatus.asAsciiCharSequence()).put(']');
reset();
return exception;
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV1.java b/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV1.java
index 7b2ff47fb..9ecba1ca0 100644
--- a/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV1.java
+++ b/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV1.java
@@ -116,14 +116,20 @@ protected LineHttpSenderV1(ObjList hosts,
@Override
public void at(long timestamp, ChronoUnit unit) {
+ // validate BEFORE writing the timestamp: a rejected row must not leave a stray timestamp in
+ // the request buffer for the next row to inherit
+ validateRowStarted();
request.putAscii(' ').put(NanosTimestampDriver.INSTANCE.from(timestamp, unit));
- atNow();
+ terminateRow(); // atNow() without the re-validation; see its javadoc
}
@Override
public void at(Instant timestamp) {
+ // validate BEFORE writing the timestamp: a rejected row must not leave a stray timestamp in
+ // the request buffer for the next row to inherit
+ validateRowStarted();
request.putAscii(' ').put(NanosTimestampDriver.INSTANCE.from(timestamp));
- atNow();
+ terminateRow(); // atNow() without the re-validation; see its javadoc
}
@Override
diff --git a/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV2.java b/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV2.java
index 00649f36f..a69b99bfa 100644
--- a/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV2.java
+++ b/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV2.java
@@ -163,16 +163,22 @@ protected LineHttpSenderV2(
@Override
public void at(long timestamp, ChronoUnit unit) {
+ // validate BEFORE writing the timestamp: a rejected row must not leave a stray timestamp in
+ // the request buffer for the next row to inherit
+ validateRowStarted();
request.putAscii(' ');
putTimestamp(timestamp, unit);
- atNow();
+ terminateRow(); // atNow() without the re-validation; see its javadoc
}
@Override
public void at(Instant timestamp) {
+ // validate BEFORE writing the timestamp: a rejected row must not leave a stray timestamp in
+ // the request buffer for the next row to inherit
+ validateRowStarted();
request.putAscii(' ');
putTimestamp(timestamp);
- atNow();
+ terminateRow(); // atNow() without the re-validation; see its javadoc
}
@Override
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpCredentialUnavailableException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpCredentialUnavailableException.java
new file mode 100644
index 000000000..2652a7f79
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpCredentialUnavailableException.java
@@ -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.cutlass.qwp.client;
+
+import io.questdb.client.cutlass.line.LineSenderException;
+
+/**
+ * Signals that the client could not OBTAIN an Authorization credential for a
+ * (re)connect handshake: the configured {@code httpTokenProvider} threw instead of
+ * returning a token -- a failed silent refresh, or no sign-in yet.
+ *
+ * Distinct from {@link QwpAuthFailedException}, which means the server rejected a
+ * credential the client did present (a terminal auth failure). A credential the client
+ * cannot ACQUIRE is instead handled by connection phase, exactly like a transport outage:
+ * the RUNNING store-and-forward drainer retries it indefinitely with capped backoff under
+ * Invariant B -- the IdP becomes reachable again, or the user completes an interactive
+ * sign-in -- holding the un-acked rows in SF meanwhile, and NEVER bounds it by
+ * {@code reconnectMaxDurationMillis} nor latches a terminal (either would drop a producer
+ * store-and-forward promised to keep alive). Only the foreground/SYNC initial connect
+ * fails fast, because a connectivity error is the caller's to see during initialization,
+ * not after the drainer is running.
+ *
+ * It exists so the send loop can tell "the provider failed" apart from "the network
+ * failed", and it carries the provider's own exception so a handler can surface that
+ * instead of this wrapper.
+ *
+ * Where a caller can meet it. Not from the ordinary sender API: no path out of
+ * {@code build()}, {@code flush()} or any row call delivers this type. The foreground
+ * connects - SYNC in {@code CursorWebSocketSendLoop.connectWithRetry}, and the OFF-mode
+ * connect in {@code QwpWebSocketSender} - both catch it and rethrow
+ * {@link #providerFailure()}, so a token-provider failure reaches the caller as the
+ * provider's own exception; the running background drainer catches it and retries under
+ * the invariant above. It is public because both of those packages handle it, and
+ * because {@code QwpWebSocketSender.newReconnectFactory()} is public: a caller that
+ * drives {@code ReconnectFactory.reconnect()} itself runs the endpoint walk directly and
+ * so can receive this type unwrapped. Such a caller should treat it as the provider
+ * having failed rather than the cluster, and unwrap it with {@link #providerFailure()}
+ * the way the two foreground paths do.
+ */
+public class QwpCredentialUnavailableException extends LineSenderException {
+ private final RuntimeException providerFailure;
+
+ public QwpCredentialUnavailableException(RuntimeException providerFailure) {
+ super(providerFailure.getMessage() == null
+ ? "token provider failed to supply a credential"
+ : providerFailure.getMessage(), providerFailure);
+ this.providerFailure = providerFailure;
+ }
+
+ /**
+ * The exception the token provider threw, for a caller that must surface the
+ * provider's own error rather than this wrapper. Never null: the wrapper is only
+ * ever constructed around a provider failure.
+ */
+ public RuntimeException providerFailure() {
+ return providerFailure;
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
index 1bc478dc4..bfc924e52 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
@@ -25,10 +25,12 @@
package io.questdb.client.cutlass.qwp.client;
import io.questdb.client.ClientTlsConfiguration;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.cutlass.http.client.HttpClientException;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
+import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
import io.questdb.client.impl.ConfigString;
import io.questdb.client.impl.ConfigView;
@@ -293,6 +295,11 @@ public class QwpQueryClient implements QuietCloseable {
private boolean tlsEnabled;
// Only meaningful when tlsEnabled. Default is full validation against the JVM's trust store.
private int tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
+ // Supplies a fresh Bearer token at each WebSocket upgrade (the initial
+ // connect and every failover reconnect), so a long-lived client follows
+ // token rotation. Mutually exclusive with the fixed authorizationHeader
+ // synthesized by withBearerToken/withBasicAuth; null when unset.
+ private HttpTokenProvider tokenProvider;
private char[] trustStorePassword;
private String trustStorePath;
private volatile WebSocketClient webSocketClient;
@@ -645,6 +652,26 @@ public void close() {
}
connected = false;
lastCloseTimedOut = false;
+ // Teardown must not be cancellable by a flag the CALLER merely arrived with. Thread.join(long)
+ // throws InterruptedException the instant the calling thread's flag is set, WITHOUT ever looking
+ // at whether the I/O thread has exited -- so a carried flag turns the join below into an
+ // immediate throw and takes the "could not join" return, skipping closePool() and
+ // webSocketClient.close(). Those are the only frees for sendScratch, the decoder and the
+ // batch-buffer pool, and there is no second attempt to preserve them for: closedFlag was CAS'd
+ // on entry, so every later close() returns at the guard above, and a pooled worker has already
+ // been removed from QueryClientPool.all by reapIdle() before shutdown() gets here, so the pool's
+ // own close() never sees it either. The leak is permanent and silent.
+ //
+ // This is not hypothetical: PoolHousekeeper.stop() interrupts the housekeeper thread to break a
+ // recovery build's credential pull, and that same thread runs queryPool.reapIdle() straight
+ // afterwards with the flag still set.
+ //
+ // Clear it for the duration and restore it in the finally -- the interrupt-neutral shape
+ // FileTokenStore.load()/save() already use, and bounded by shutdownJoinMs. The timeout branch
+ // below is unaffected: with the flag cleared the join really waits, so a genuinely stuck I/O
+ // thread still takes the leak-rather-than-SIGSEGV path, and an interrupt delivered DURING the
+ // wait still means "we could not join" and still returns.
+ final boolean wasInterrupted = Thread.interrupted();
try {
if (ioThread != null) {
ioThread.shutdown();
@@ -693,6 +720,11 @@ public void close() {
// (submitQuery copies its bytes into sendScratch), so it is safe to free
// even when we otherwise leak the I/O thread and buffer pool.
bindValues.close();
+ if (wasInterrupted) {
+ // Hand the caller's cancellation back exactly as it arrived. Restoring it here rather
+ // than earlier keeps it out of the joins above, which is the whole point.
+ Thread.currentThread().interrupt();
+ }
}
}
@@ -713,6 +745,11 @@ public void close() {
* observed so callers can distinguish "no primary available" from "all
* endpoints unreachable" (the latter surfaces as a plain
* {@link HttpClientException}).
+ *
+ * A configured token provider is queried once here, before the walk. A
+ * provider failure (not signed in, a failed silent refresh, a rejected
+ * token) is cluster-wide, so it fails fast with the provider's own error
+ * rather than being retried across endpoints as a transport failure.
*/
public synchronized void connect() {
if (closedFlag.get()) {
@@ -731,6 +768,12 @@ public synchronized void connect() {
QwpServerInfo lastObservedMismatch = null;
QwpIngressRoleRejectedException lastUpgradeRoleReject = null;
Throwable lastTransportError = null;
+ // Resolve the bearer credential once, before the endpoint walk: a token is cluster-wide, so a
+ // token-provider failure (not signed in, a failed silent refresh, a rejected token) is not a
+ // per-endpoint transport fault. Resolving here lets it propagate as the provider's own error
+ // instead of being folded into "all endpoints unreachable", and avoids re-querying the provider
+ // once per endpoint.
+ String authHeader = resolveAuthorizationHeader();
while (true) {
int i = hostTracker.pickNext();
if (i < 0) {
@@ -738,7 +781,7 @@ public synchronized void connect() {
}
Endpoint ep = endpoints.get(i);
try {
- connectToEndpoint(ep);
+ connectToEndpoint(ep, authHeader);
} catch (QwpAuthFailedException ae) {
cleanupFailedConnect();
throw ae;
@@ -895,11 +938,12 @@ public int getCompressionLevelForTest() {
/**
* Test-only hook: the synthesized {@code Authorization} header value
* ({@code Basic ...} or {@code Bearer ...}), or null when no credentials
- * were configured.
+ * were configured. When a token provider is configured, queries it and
+ * validates the returned token, exactly as a real upgrade would.
*/
@TestOnly
public String getAuthorizationHeaderForTest() {
- return authorizationHeader;
+ return resolveAuthorizationHeader();
}
/**
@@ -1082,6 +1126,9 @@ public QwpQueryClient withConnectTimeout(int connectTimeoutMs) {
*/
public QwpQueryClient withBasicAuth(String username, String password) {
checkPreConnect("withBasicAuth");
+ if (tokenProvider != null) {
+ throw new IllegalStateException("withBasicAuth cannot be combined with withBearerTokenProvider");
+ }
if (username == null || password == null) {
throw new IllegalArgumentException("username and password must not be null");
}
@@ -1099,6 +1146,9 @@ public QwpQueryClient withBasicAuth(String username, String password) {
*/
public QwpQueryClient withBearerToken(String token) {
checkPreConnect("withBearerToken");
+ if (tokenProvider != null) {
+ throw new IllegalStateException("withBearerToken cannot be combined with withBearerTokenProvider");
+ }
if (token == null) {
throw new IllegalArgumentException("token must not be null");
}
@@ -1106,6 +1156,36 @@ public QwpQueryClient withBearerToken(String token) {
return this;
}
+ /**
+ * Configures HTTP Bearer authentication with a token supplied on demand by
+ * {@code provider}, instead of the fixed token captured once by
+ * {@link #withBearerToken(String)}. The provider is queried for a fresh
+ * token at every WebSocket upgrade -- the initial {@link #connect()} and
+ * each failover reconnect -- so a long-lived client keeps working as the
+ * token rotates (for example an OIDC device-flow token:
+ * {@code .withBearerTokenProvider(auth::getToken)}).
+ *
+ * {@link HttpTokenProvider#getToken()} runs on the connect and reconnect
+ * paths, so it must return promptly and must not block on interactive
+ * input; a quick silent refresh is fine. Each returned token is validated
+ * ({@link HttpTokenProvider#validateToken(CharSequence)}) before it is sent,
+ * and a provider that throws fails that connection attempt. Mutually
+ * exclusive with {@link #withBearerToken(String)} and
+ * {@link #withBasicAuth(String, String)}. Must be called before
+ * {@link #connect}.
+ */
+ public QwpQueryClient withBearerTokenProvider(HttpTokenProvider provider) {
+ checkPreConnect("withBearerTokenProvider");
+ if (provider == null) {
+ throw new IllegalArgumentException("provider must not be null");
+ }
+ if (authorizationHeader != null) {
+ throw new IllegalStateException("withBearerTokenProvider cannot be combined with withBearerToken or withBasicAuth");
+ }
+ this.tokenProvider = provider;
+ return this;
+ }
+
/**
* Overrides the default I/O buffer pool depth (4). Larger pools let the
* I/O thread decode further ahead of the consumer at the cost of memory;
@@ -1458,7 +1538,7 @@ private void cleanupFailedConnect() {
currentEndpointIndex = -1;
}
- private void connectToEndpoint(Endpoint ep) {
+ private void connectToEndpoint(Endpoint ep, String authHeader) {
if (tlsEnabled) {
webSocketClient = WebSocketClientFactory.newTlsInstance(
new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode));
@@ -1470,7 +1550,7 @@ private void connectToEndpoint(Endpoint ep) {
webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader());
webSocketClient.setQwpMaxBatchRows(maxBatchRows);
webSocketClient.setConnectTimeout(connectTimeoutMs);
- runUpgradeWithTimeout(ep);
+ runUpgradeWithTimeout(ep, authHeader);
negotiatedQwpVersion = webSocketClient.getServerQwpVersion();
negotiatedZstdLevel = webSocketClient.getServerNegotiatedZstdLevel();
@@ -1789,6 +1869,10 @@ private void reconnectViaTracker() {
QwpServerInfo lastMismatch = null;
Throwable lastError = null;
boolean retriedAfterReset = false;
+ // Resolve the bearer credential once per reconnect, before the endpoint walk, for the same
+ // reason as connect(): a provider failure is cluster-wide, so surface it directly rather than
+ // as a per-endpoint transport error retried across every host.
+ String authHeader = resolveAuthorizationHeader();
while (true) {
int i = hostTracker.pickNext();
if (i < 0) {
@@ -1801,7 +1885,7 @@ private void reconnectViaTracker() {
}
Endpoint ep = endpoints.get(i);
try {
- connectToEndpoint(ep);
+ connectToEndpoint(ep, authHeader);
} catch (QwpAuthFailedException ae) {
cleanupFailedConnect();
throw ae;
@@ -1846,6 +1930,34 @@ private void reconnectViaTracker() {
+ ", lastError=" + (lastError == null ? "" : lastError.getMessage()) + ']');
}
+ private String resolveAuthorizationHeader() {
+ // With a token provider, query it once per connect()/reconnect (the caller resolves before the
+ // endpoint walk) so a reconnect presents a freshly refreshed token; validateToken rejects a
+ // null/empty/blank return, or one carrying a control or non-ASCII character, before it reaches
+ // the "Bearer " header. A provider that throws (a failed silent refresh, or not signed in yet)
+ // fails connect()/reconnect as a LineSenderException, preserving the provider failure as its cause.
+ if (tokenProvider != null) {
+ CharSequence pulled;
+ try {
+ pulled = tokenProvider.getToken();
+ } catch (LineSenderException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new LineSenderException(
+ e.getMessage() == null
+ ? "token provider failed to supply a credential"
+ : e.getMessage(),
+ e);
+ }
+ // snapshot before validating, for the reason HttpTokenProvider.validateToken gives: the
+ // concatenation below re-reads the sequence, and the provider may be reusing its buffer
+ CharSequence token = pulled == null ? null : pulled.toString();
+ HttpTokenProvider.validateToken(token);
+ return "Bearer " + token;
+ }
+ return authorizationHeader;
+ }
+
private long resolveQueryFlags(boolean resetSymbolDict) {
if (!resetSymbolDict) {
return 0L;
@@ -1856,7 +1968,7 @@ private long resolveQueryFlags(boolean resetSymbolDict) {
: 0L;
}
- private void runUpgradeWithTimeout(Endpoint ep) {
+ private void runUpgradeWithTimeout(Endpoint ep, String authHeader) {
// Connect first, OUTSIDE the upgrade try. A connect-phase failure --
// including a connect_timeout overage flagged via flagAsTimeout() -- must
// keep its own message ("connect timed out ...") and must NOT be relabeled
@@ -1867,7 +1979,7 @@ private void runUpgradeWithTimeout(Endpoint ep) {
int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE);
try {
- webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authorizationHeader);
+ webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader);
} catch (HttpClientException ex) {
if (ex.isTimeout()) {
// Reachable only for an upgrade/auth-phase timeout now, so the
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpUdpSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpUdpSender.java
index f02e87c39..f13b1f516 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpUdpSender.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpUdpSender.java
@@ -1413,7 +1413,10 @@ private void validateTableName(CharSequence name) {
if (name.length() > MAX_TABLE_NAME_LENGTH) {
throw new LineSenderException("table name too long [maxLength=" + MAX_TABLE_NAME_LENGTH + "]");
}
- throw new LineSenderException("table name contains illegal characters: " + name);
+ // sanitize the rejected name before it reaches the message (and any log/terminal): a name that
+ // failed validation can carry BOM/bidi/zero-width/control chars that would otherwise reorder, hide
+ // or forge what a human reads, matching how the ILP name/error render escapes untrusted text
+ throw new LineSenderException("table name contains illegal characters: ").putAsPrintable(name);
}
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java
index 7aed14192..7b72c3807 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java
@@ -79,6 +79,7 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Supplier;
/**
* QWP v1 WebSocket client sender for streaming data to QuestDB.
@@ -145,7 +146,15 @@ public class QwpWebSocketSender implements Sender {
// enough window to preserve the trailing category distribution.
private static final int MIN_ERROR_INBOX_CAPACITY = 16;
private static final String WRITE_PATH = "/write/v4";
- private final String authorizationHeader;
+ // Yields the Authorization header value presented on each WebSocket upgrade. A constant for a
+ // fixed token or Basic credential; for an httpTokenProvider it pulls a freshly refreshed token,
+ // so the initial connect and every reconnect re-handshake carry the current token. May be null
+ // when no auth is configured. Evaluated once per (re)connect round in buildAndConnect, before the
+ // endpoint walk (not once per endpoint); a throwing provider is wrapped as
+ // QwpCredentialUnavailableException: the foreground/SYNC initial connect fails fast with the provider's
+ // own exception, while the running background drainer treats it as a transient outage and retries it
+ // indefinitely (never bounded by the reconnect budget, never terminal) per store-and-forward Invariant B.
+ private final Supplier authorizationHeaderSupplier;
private final int autoFlushBytes;
private final long autoFlushIntervalNanos;
// Auto-flush configuration
@@ -410,14 +419,14 @@ private QwpWebSocketSender(
int autoFlushRows,
int autoFlushBytes,
long autoFlushIntervalNanos,
- String authorizationHeader
+ Supplier authorizationHeaderSupplier
) {
if (endpoints == null || endpoints.isEmpty()) {
throw new IllegalArgumentException("endpoints must be non-empty");
}
this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints));
this.hostTracker = new QwpHostHealthTracker(this.endpoints.size());
- this.authorizationHeader = authorizationHeader;
+ this.authorizationHeaderSupplier = authorizationHeaderSupplier;
this.tlsConfig = tlsConfig;
this.encoder = new QwpWebSocketEncoder(DEFAULT_BUFFER_SIZE);
this.tableBuffers = new CharSequenceObjHashMap<>();
@@ -700,8 +709,8 @@ public static QwpWebSocketSender connect(
long durableAckKeepaliveIntervalMillis,
long authTimeoutMs
) {
- return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
- autoFlushIntervalNanos, authorizationHeader,
+ return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
+ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
requestDurableAck, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
@@ -710,18 +719,67 @@ public static QwpWebSocketSender connect(
0, null, SenderConnectionDispatcher.DEFAULT_CAPACITY);
}
+ /**
+ * Constant-credential form of the connection-listener variant below, kept so callers compiled against
+ * the {@code String authorizationHeader} signature keep linking after the parameter became a
+ * {@link Supplier}. Wraps the header with {@link #fixedAuthHeader(String)}, which also tags it as a
+ * CONSTANT credential for the store-and-forward drainer's terminal policy -- the same thing the older
+ * signature implied.
+ *
+ * A rotating credential goes to {@code connectWithCredentialSupplier} instead, which carries a distinct
+ * name precisely so this form keeps its exact descriptor and a bare {@code null} credential stays
+ * unambiguous.
+ */
+ public static QwpWebSocketSender connect(
+ List endpoints,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity,
+ long durableAckKeepaliveIntervalMillis,
+ long authTimeoutMs,
+ int connectTimeoutMs,
+ SenderConnectionListener connectionListener,
+ int connectionListenerInboxCapacity
+ ) {
+ return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
+ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
+ requestDurableAck, cursorEngine,
+ closeFlushTimeoutMillis, reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+ initialConnectMode, errorHandler, errorInboxCapacity,
+ durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs,
+ connectionListener, connectionListenerInboxCapacity);
+ }
+
/**
* Multi-endpoint variant that also accepts the async connection-event
* listener and its dispatcher inbox capacity. Uses the default
* poison-frame detector threshold.
+ *
+ * Named apart from {@code connect} rather than overloading it: the constant-credential
+ * {@code connect(..., String, ...)} form must keep its exact descriptor for callers compiled against
+ * it, and a {@code String} / {@code Supplier} overload pair of equal arity makes a bare
+ * {@code null} credential argument ambiguous -- neither parameter type is more specific than the
+ * other. A distinct name keeps both forms callable with no cast.
*/
- public static QwpWebSocketSender connect(
+ public static QwpWebSocketSender connectWithCredentialSupplier(
List endpoints,
ClientTlsConfiguration tlsConfig,
int autoFlushRows,
int autoFlushBytes,
long autoFlushIntervalNanos,
- String authorizationHeader,
+ Supplier authorizationHeaderSupplier,
boolean requestDurableAck,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
@@ -737,8 +795,8 @@ public static QwpWebSocketSender connect(
SenderConnectionListener connectionListener,
int connectionListenerInboxCapacity
) {
- return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
- autoFlushIntervalNanos, authorizationHeader, requestDurableAck,
+ return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
+ autoFlushIntervalNanos, authorizationHeaderSupplier, requestDurableAck,
cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -750,18 +808,71 @@ public static QwpWebSocketSender connect(
}
/**
- * Master connect overload — also accepts the poison-frame detector
+ * Constant-credential form of the master overload below, kept so callers compiled against the
+ * {@code String authorizationHeader} signature keep linking after the parameter became a
+ * {@link Supplier}. Wraps the header with {@link #fixedAuthHeader(String)}, which also tags it as a
+ * CONSTANT credential for the store-and-forward drainer's terminal policy -- the same thing the older
+ * signature implied.
+ *
+ * A rotating credential goes to {@code connectWithCredentialSupplier} instead, which carries a distinct
+ * name precisely so this form keeps its exact descriptor and a bare {@code null} credential stays
+ * unambiguous.
+ */
+ public static QwpWebSocketSender connect(
+ List endpoints,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity,
+ long durableAckKeepaliveIntervalMillis,
+ long authTimeoutMs,
+ int connectTimeoutMs,
+ SenderConnectionListener connectionListener,
+ int connectionListenerInboxCapacity,
+ int maxFrameRejections,
+ long poisonMinEscalationWindowMillis,
+ long catchUpCapGapMinEscalationWindowMillis
+ ) {
+ return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
+ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
+ requestDurableAck, cursorEngine,
+ closeFlushTimeoutMillis, reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+ initialConnectMode, errorHandler, errorInboxCapacity,
+ durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs,
+ connectionListener, connectionListenerInboxCapacity,
+ maxFrameRejections, poisonMinEscalationWindowMillis,
+ catchUpCapGapMinEscalationWindowMillis);
+ }
+
+ /**
+ * Master connect entry point — also accepts the poison-frame detector
* threshold ({@code max_frame_rejections}): consecutive server-active
* rejections of the same head-of-line frame, with no ack progress in
* between, before the loop escalates to a typed terminal.
+ *
+ * Named apart from {@code connect} for the reason given on
+ * {@link #connectWithCredentialSupplier(List, ClientTlsConfiguration, int, int, long, Supplier,
+ * boolean, CursorSendEngine, long, long, long, long, Sender.InitialConnectMode, SenderErrorHandler,
+ * int, long, long, int, SenderConnectionListener, int)}.
*/
- public static QwpWebSocketSender connect(
+ public static QwpWebSocketSender connectWithCredentialSupplier(
List endpoints,
ClientTlsConfiguration tlsConfig,
int autoFlushRows,
int autoFlushBytes,
long autoFlushIntervalNanos,
- String authorizationHeader,
+ Supplier authorizationHeaderSupplier,
boolean requestDurableAck,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
@@ -783,7 +894,7 @@ public static QwpWebSocketSender connect(
QwpWebSocketSender sender = new QwpWebSocketSender(
endpoints, tlsConfig,
autoFlushRows, autoFlushBytes, autoFlushIntervalNanos,
- authorizationHeader
+ authorizationHeaderSupplier
);
try {
sender.requestDurableAck = requestDurableAck;
@@ -850,7 +961,7 @@ public static QwpWebSocketSender createForTesting(String host, int port, String
return new QwpWebSocketSender(
singleEndpoint(host, port), null,
DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS,
- authorizationHeader
+ fixedAuthHeader(authorizationHeader)
);
}
@@ -878,6 +989,26 @@ public static QwpWebSocketSender createForTesting(
);
}
+ /**
+ * Wraps a CONSTANT {@code Authorization} header value as a supplier, tagged so the store-and-forward
+ * drainer can tell it apart from an {@code httpTokenProvider}-backed rotating credential. Callers that
+ * synthesize a fixed header (a static bearer token, a Basic credential) must route it through here
+ * rather than through a bare lambda, or the drainer misreads the credential as rotating.
+ *
+ * The distinction is load-bearing for the orphan drainer's terminal policy. A {@code 401} against a
+ * fixed credential is a permanent misconfiguration, so quarantining the slot immediately is right. The
+ * same {@code 401} against a rotating credential can be a recoverable window - clock skew past the
+ * token's skew margin, a mid-flight revocation, an identity provider rotating its signing keys - where
+ * a later attempt carrying a freshly pulled token succeeds. See
+ * {@code BackgroundDrainer.connectWithDurableAckRetry}.
+ *
+ * @param header the constant header value, or null when no credential is configured
+ * @return a tagged supplier yielding {@code header}, or null when {@code header} is null
+ */
+ public static Supplier fixedAuthHeader(String header) {
+ return header == null ? null : new FixedAuthHeader(header);
+ }
+
@Override
public void at(long timestamp, ChronoUnit unit) {
checkNotClosed();
@@ -1146,205 +1277,231 @@ public QwpWebSocketSender charColumn(CharSequence columnName, char value) {
public void close() {
if (!closed) {
closed = true;
- Runnable hook = closeStartedHook;
- closeStartedHook = null;
- if (hook != null) {
- try {
- hook.run();
- } catch (Throwable t) {
- // A test witness must never prevent production resource cleanup.
- LOG.error("Error in close-started test hook: {}", String.valueOf(t));
+ // Interrupt-neutral for the duration, the same shape QwpQueryClient.close() and
+ // FileTokenStore.load()/save() use. PoolHousekeeper.stop() and
+ // SenderPool.stopStartupRecoveryDriver() escalate to Thread.interrupt() when their join
+ // times out, and the thread they interrupt is the one that then runs senderPool.reapIdle()
+ // and the startup-recovery step's finally -- both of which close a delegate. A CARRIED flag
+ // is fatal to that close: CountDownLatch.await(t, u) tests Thread.interrupted() before it
+ // ever consults the latch, so CursorWebSocketSendLoop.close()'s shutdown await would throw
+ // having waited 0 ms, take the failed-stop path, and report the SF slot flock still held --
+ // the exact outcome the interrupt was added to prevent. Worse, that path re-asserts the
+ // flag, so every remaining delegate in the same reap sweep failed the same way.
+ //
+ // Clearing it here restores the intended meaning: the interrupt breaks the wait it was
+ // aimed at (a credential pull between steps), and the teardown that follows runs normally.
+ // An interrupt delivered DURING this close still lands on the await and still takes the
+ // failed-stop branch, which is correct -- that one really is 'we could not join'.
+ final boolean wasInterrupted = Thread.interrupted();
+ try {
+ close0();
+ } finally {
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt();
}
}
- boolean ioThreadStopped = true;
- // Captures the first error from the flush/drain path AND any
- // secondary errors from cleanup steps (added via addSuppressed).
- // Silently swallowing any of these would hide latched terminal
- // SenderError HALTs (server-side rejections like MESSAGE_TOO_BIG,
- // SCHEMA_MISMATCH HALT) from users who only call close() and
- // never call flush() afterwards.
- Throwable terminalError = null;
- // Snapshot the exact terminal error instance that a user-thread
- // API call ALREADY caught (via flush()/at()) before close() ran.
- // If flushPendingRows/drainOnClose below also rethrow the same
- // instance, dropping it at the final rethrow avoids
- // try-with-resources self-suppression: Throwable.addSuppressed
- // raises IllegalArgumentException when primary == suppressed.
- // Must stay this single read: the snapshot needs the identity of
- // the error the user already owns, and only
- // getSynchronouslySurfacedError() holds it. Deriving it from two
- // separate latch reads races the I/O thread -- a terminal latched
- // between the reads would be adopted as user-owned and silently
- // dropped (see CloseOwnershipRaceTest).
- Throwable alreadyOwnedByUser = cursorSendLoop != null
- ? cursorSendLoop.getSynchronouslySurfacedError() : null;
+ }
+ }
+ private void close0() {
+ Runnable hook = closeStartedHook;
+ closeStartedHook = null;
+ if (hook != null) {
try {
- // Only drain when both the engine and the I/O loop are wired
- // up — close() is also called from createForTesting() teardown
- // and from connect() rollback paths where one or both may be null.
- if (connectionError.get() == null && cursorEngine != null && cursorSendLoop != null) {
- // 1) Flush user-thread state into the engine (encoded
- // rows -> mmap'd / malloc'd ring). After this, the
- // cursor engine's publishedFsn reflects the final
- // target the I/O loop must drive ackedFsn up to.
- // A pre-flight rejection means this batch cannot fit
- // the current cap however it is split. It is
- // RETAINED by design so it can go out once a
- // larger-cap node is reached -- but on close there is
- // no later flush, and letting the throw escape here
- // skips sendCommitMessage, sealAndSwapBuffer and
- // drainOnClose, abandoning every row an earlier
- // successful flush already published. The message
- // that path emits tells the caller to close the
- // sender to discard the batch, so honour that:
- // discard it, remember the error, and let the rest of
- // close() run. rethrowTerminal below still surfaces it.
- try {
- flushPendingRows(deferCommit);
- } catch (BatchTooLargeForCapException e) {
- resetTableBuffersAfterFlush();
- terminalError = captureCloseError(terminalError, e);
- } catch (Throwable t) {
- // Same reasoning as the pre-flight rejection above, for the
- // failures a size check cannot see: sealAndSwapBuffer's
- // buffer-recycle timeout and appendBlocking's backpressure
- // deadline. Letting those escape to the outer catch skipped
- // sendCommitMessage, sealAndSwapBuffer and drainOnClose -- so a
- // flush that had already published deferred dictionary chunks
- // left their group open forever, and every row an EARLIER
- // successful flush published was abandoned unacked. The batch is
- // NOT discarded here (unlike the over-cap case, this failure is
- // not a verdict on the batch's contents), but the rest of close()
- // must still run. rethrowTerminal below surfaces it.
- terminalError = captureCloseError(terminalError, t);
- }
- if (!deferCommit && hasDeferredMessages) {
- sendCommitMessage();
- }
- if (activeBuffer != null && activeBuffer.hasData()) {
- sealAndSwapBuffer();
- if (!deferCommit) {
- lastCommitBoundaryFsn = cursorEngine.publishedFsn();
- }
- }
- // 2) Safety-net rethrow: surface the latched terminal
- // error only when no other channel has already
- // delivered THIS terminal to the user. "Already
- // delivered" means either the producer thread saw it
- // synchronously via flush()/append() (checkUnsurfacedError
- // is silent in that case) or the async dispatcher
- // actually delivered the latched terminal to a
- // user-installed custom handler
- // (hasDeliveredTerminalToCustomHandler, checked here).
- // The test is terminal-specific on purpose: an earlier
- // routine RETRIABLE rejection delivered to the
- // handler must NOT suppress a later genuine TERMINAL
- // error (the "any error ever" flag did, silently
- // losing it). It also stays false when the terminal
- // reached only the default handler after a
- // setErrorHandler(null) revert, or is still
- // queued/abandoned behind a slow handler -- so a
- // config-string-only caller, and a reverting caller,
- // both still get the loud rethrow on shutdown.
- boolean terminalOwnedByCustomHandler = errorDispatcher != null
- && errorDispatcher.hasDeliveredTerminalToCustomHandler();
- if (!terminalOwnedByCustomHandler) {
- cursorSendLoop.checkUnsurfacedError();
- }
- // 3) Bounded drain: block until the server has ACK'd
- // everything we just published, or until the
- // configured timeout elapses. closeFlushTimeoutMillis
- // <= 0 opts out (fast close, may lose memory-mode
- // data on JVM exit). Pass the same ownership flag the
- // step-2 safety net used: when the custom handler
- // already owns THIS terminal, the drain must stop on it
- // without re-throwing (re-throwing would double-signal
- // an error the user already handled). Otherwise the
- // drain keeps the loud safety net and surfaces it.
- if (closeFlushTimeoutMillis > 0L) {
- drainOnClose(terminalOwnedByCustomHandler);
- }
- }
+ hook.run();
} catch (Throwable t) {
- terminalError = t;
+ // A test witness must never prevent production resource cleanup.
+ LOG.error("Error in close-started test hook: {}", String.valueOf(t));
}
+ }
+ boolean ioThreadStopped = true;
+ // Captures the first error from the flush/drain path AND any
+ // secondary errors from cleanup steps (added via addSuppressed).
+ // Silently swallowing any of these would hide latched terminal
+ // SenderError HALTs (server-side rejections like MESSAGE_TOO_BIG,
+ // SCHEMA_MISMATCH HALT) from users who only call close() and
+ // never call flush() afterwards.
+ Throwable terminalError = null;
+ // Snapshot the exact terminal error instance that a user-thread
+ // API call ALREADY caught (via flush()/at()) before close() ran.
+ // If flushPendingRows/drainOnClose below also rethrow the same
+ // instance, dropping it at the final rethrow avoids
+ // try-with-resources self-suppression: Throwable.addSuppressed
+ // raises IllegalArgumentException when primary == suppressed.
+ // Must stay this single read: the snapshot needs the identity of
+ // the error the user already owns, and only
+ // getSynchronouslySurfacedError() holds it. Deriving it from two
+ // separate latch reads races the I/O thread -- a terminal latched
+ // between the reads would be adopted as user-owned and silently
+ // dropped (see CloseOwnershipRaceTest).
+ Throwable alreadyOwnedByUser = cursorSendLoop != null
+ ? cursorSendLoop.getSynchronouslySurfacedError() : null;
- // Shut down the I/O thread before closing the socket or buffers
- // it may be using. Must run even if the flush above failed.
- if (cursorSendLoop != null) {
+ try {
+ // Only drain when both the engine and the I/O loop are wired
+ // up — close() is also called from createForTesting() teardown
+ // and from connect() rollback paths where one or both may be null.
+ if (connectionError.get() == null && cursorEngine != null && cursorSendLoop != null) {
+ // 1) Flush user-thread state into the engine (encoded
+ // rows -> mmap'd / malloc'd ring). After this, the
+ // cursor engine's publishedFsn reflects the final
+ // target the I/O loop must drive ackedFsn up to.
+ // A pre-flight rejection means this batch cannot fit
+ // the current cap however it is split. It is
+ // RETAINED by design so it can go out once a
+ // larger-cap node is reached -- but on close there is
+ // no later flush, and letting the throw escape here
+ // skips sendCommitMessage, sealAndSwapBuffer and
+ // drainOnClose, abandoning every row an earlier
+ // successful flush already published. The message
+ // that path emits tells the caller to close the
+ // sender to discard the batch, so honour that:
+ // discard it, remember the error, and let the rest of
+ // close() run. rethrowTerminal below still surfaces it.
try {
- cursorSendLoop.close();
- } catch (Throwable e) {
- ioThreadStopped = false;
- LOG.error("Error closing cursor send loop: {}", String.valueOf(e));
+ flushPendingRows(deferCommit);
+ } catch (BatchTooLargeForCapException e) {
+ resetTableBuffersAfterFlush();
terminalError = captureCloseError(terminalError, e);
+ } catch (Throwable t) {
+ // Same reasoning as the pre-flight rejection above, for the
+ // failures a size check cannot see: sealAndSwapBuffer's
+ // buffer-recycle timeout and appendBlocking's backpressure
+ // deadline. Letting those escape to the outer catch skipped
+ // sendCommitMessage, sealAndSwapBuffer and drainOnClose -- so a
+ // flush that had already published deferred dictionary chunks
+ // left their group open forever, and every row an EARLIER
+ // successful flush published was abandoned unacked. The batch is
+ // NOT discarded here (unlike the over-cap case, this failure is
+ // not a verdict on the batch's contents), but the rest of close()
+ // must still run. rethrowTerminal below surfaces it.
+ terminalError = captureCloseError(terminalError, t);
}
- }
- // Drainer pool closes after the foreground I/O loop is wound
- // down. Drainers share buildAndConnect's endpoint walk and
- // hostTracker state with the foreground (never its observable
- // connection state or event stream), but their
- // connect gate is their own stop flag — NOT the foreground
- // loop's liveness — so the pool's graceful-drain window below
- // still lets in-flight drainers finish (including reconnects)
- // even though cursorSendLoop is already stopped.
- if (drainerPool != null) {
- try {
- drainerPool.close();
- } catch (Throwable e) {
- LOG.error("Error closing drainer pool: {}", String.valueOf(e));
- terminalError = captureCloseError(terminalError, e);
+ if (!deferCommit && hasDeferredMessages) {
+ sendCommitMessage();
+ }
+ if (activeBuffer != null && activeBuffer.hasData()) {
+ sealAndSwapBuffer();
+ if (!deferCommit) {
+ lastCommitBoundaryFsn = cursorEngine.publishedFsn();
+ }
+ }
+ // 2) Safety-net rethrow: surface the latched terminal
+ // error only when no other channel has already
+ // delivered THIS terminal to the user. "Already
+ // delivered" means either the producer thread saw it
+ // synchronously via flush()/append() (checkUnsurfacedError
+ // is silent in that case) or the async dispatcher
+ // actually delivered the latched terminal to a
+ // user-installed custom handler
+ // (hasDeliveredTerminalToCustomHandler, checked here).
+ // The test is terminal-specific on purpose: an earlier
+ // routine RETRIABLE rejection delivered to the
+ // handler must NOT suppress a later genuine TERMINAL
+ // error (the "any error ever" flag did, silently
+ // losing it). It also stays false when the terminal
+ // reached only the default handler after a
+ // setErrorHandler(null) revert, or is still
+ // queued/abandoned behind a slow handler -- so a
+ // config-string-only caller, and a reverting caller,
+ // both still get the loud rethrow on shutdown.
+ boolean terminalOwnedByCustomHandler = errorDispatcher != null
+ && errorDispatcher.hasDeliveredTerminalToCustomHandler();
+ if (!terminalOwnedByCustomHandler) {
+ cursorSendLoop.checkUnsurfacedError();
+ }
+ // 3) Bounded drain: block until the server has ACK'd
+ // everything we just published, or until the
+ // configured timeout elapses. closeFlushTimeoutMillis
+ // <= 0 opts out (fast close, may lose memory-mode
+ // data on JVM exit). Pass the same ownership flag the
+ // step-2 safety net used: when the custom handler
+ // already owns THIS terminal, the drain must stop on it
+ // without re-throwing (re-throwing would double-signal
+ // an error the user already handled). Otherwise the
+ // drain keeps the loud safety net and surfaces it.
+ if (closeFlushTimeoutMillis > 0L) {
+ drainOnClose(terminalOwnedByCustomHandler);
}
}
+ } catch (Throwable t) {
+ terminalError = t;
+ }
- // Always free resources the I/O thread never touches:
- // encoder and table buffers are user-thread-only.
+ // Shut down the I/O thread before closing the socket or buffers
+ // it may be using. Must run even if the flush above failed.
+ if (cursorSendLoop != null) {
try {
- encoder.close();
- ObjList keys = tableBuffers.keys();
- for (int i = 0, n = keys.size(); i < n; i++) {
- CharSequence key = keys.getQuick(i);
- if (key != null) {
- Misc.free(tableBuffers.get(key));
- }
- }
- tableBuffers.clear();
- } catch (Throwable t) {
- LOG.error("Error closing encoder or table buffers: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
+ cursorSendLoop.close();
+ } catch (Throwable e) {
+ ioThreadStopped = false;
+ LOG.error("Error closing cursor send loop: {}", String.valueOf(e));
+ terminalError = captureCloseError(terminalError, e);
+ }
+ }
+ // Drainer pool closes after the foreground I/O loop is wound
+ // down. Drainers share buildAndConnect's endpoint walk and
+ // hostTracker state with the foreground (never its observable
+ // connection state or event stream), but their
+ // connect gate is their own stop flag — NOT the foreground
+ // loop's liveness — so the pool's graceful-drain window below
+ // still lets in-flight drainers finish (including reconnects)
+ // even though cursorSendLoop is already stopped.
+ if (drainerPool != null) {
+ try {
+ drainerPool.close();
+ } catch (Throwable e) {
+ LOG.error("Error closing drainer pool: {}", String.valueOf(e));
+ terminalError = captureCloseError(terminalError, e);
}
+ }
- if (!ioThreadStopped) {
- // The worker may still touch every resource below. Hand the
- // complete sender-owned tail to its exit path rather than
- // permanently leaking everything except the engine. The
- // callback is idempotence-gated by closeRemainingResources().
- if (ownsCursorEngine && cursorEngine != null) {
- retainedEngine = cursorEngine;
- }
- Runnable closeCallback = () -> closeRemainingResources(null);
- if (cursorSendLoop != null && cursorSendLoop.delegateClose(closeCallback)) {
- rethrowTerminal(terminalError);
- return;
+ // Always free resources the I/O thread never touches:
+ // encoder and table buffers are user-thread-only.
+ try {
+ encoder.close();
+ ObjList keys = tableBuffers.keys();
+ for (int i = 0, n = keys.size(); i < n; i++) {
+ CharSequence key = keys.getQuick(i);
+ if (key != null) {
+ Misc.free(tableBuffers.get(key));
}
- // The worker exited between close() failing and delegation.
- // Cleanup is safe here and its failures remain suppressed on
- // the original close error.
- terminalError = closeRemainingResources(terminalError);
- } else {
- terminalError = closeRemainingResources(terminalError);
}
+ tableBuffers.clear();
+ } catch (Throwable t) {
+ LOG.error("Error closing encoder or table buffers: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
- // If close() ended up holding the same instance the user already
- // caught earlier, suppress the rethrow. The user's catch block
- // wraps close() (try-with-resources), and Throwable refuses
- // self-suppression.
- if (terminalError != null && terminalError == alreadyOwnedByUser) {
- terminalError = null;
+ if (!ioThreadStopped) {
+ // The worker may still touch every resource below. Hand the
+ // complete sender-owned tail to its exit path rather than
+ // permanently leaking everything except the engine. The
+ // callback is idempotence-gated by closeRemainingResources().
+ if (ownsCursorEngine && cursorEngine != null) {
+ retainedEngine = cursorEngine;
}
- rethrowTerminal(terminalError);
+ Runnable closeCallback = () -> closeRemainingResources(null);
+ if (cursorSendLoop != null && cursorSendLoop.delegateClose(closeCallback)) {
+ rethrowTerminal(terminalError);
+ return;
+ }
+ // The worker exited between close() failing and delegation.
+ // Cleanup is safe here and its failures remain suppressed on
+ // the original close error.
+ terminalError = closeRemainingResources(terminalError);
+ } else {
+ terminalError = closeRemainingResources(terminalError);
}
+
+ // If close() ended up holding the same instance the user already
+ // caught earlier, suppress the rethrow. The user's catch block
+ // wraps close() (try-with-resources), and Throwable refuses
+ // self-suppression.
+ if (terminalError != null && terminalError == alreadyOwnedByUser) {
+ terminalError = null;
+ }
+ rethrowTerminal(terminalError);
}
@TestOnly
@@ -1979,6 +2136,23 @@ public QwpTableBuffer getTableBuffer(String tableName) {
return buffer;
}
+ /**
+ * Test seam over {@link #hasDynamicCredential()}: whether this sender's configured
+ * credential is re-derived per handshake (an {@code httpTokenProvider}) rather than
+ * captured once (an {@code httpToken} or {@code httpUsernamePassword}).
+ *
+ * The tag is set by the builder, several classes away from the orphan drainer whose
+ * terminal policy consumes it, and a mis-tag is silent at build time. Tagging a
+ * rotating credential as fixed makes the first {@code 401} of an orphan drain drop a
+ * {@code .failed} sentinel that nothing in production clears -- replayable rows
+ * abandoned for good over a token the next pull would have refreshed. Hence a seam a
+ * test can assert on a real, built sender.
+ */
+ @TestOnly
+ public boolean isCredentialDynamic() {
+ return hasDynamicCredential();
+ }
+
/**
* Whether this sender is still in delta-encoded mode. Flips to {@code false}
* permanently once {@link #disableDeltaDict} fires (a persisted-dictionary
@@ -2653,12 +2827,15 @@ public synchronized void startOrphanDrainers(
// Install the user listener as the pool's submit-time default so
// the drainers submitted below observe it from their first event.
drainerPool.setListener(this.drainerListener);
- // Route drainer data-loss reports through the sender's own error
+ // Route the drainers' reports through the sender's own error
// dispatcher: async, bounded, and contained exactly like every
- // other SenderError. The dispatcher field is read lazily because
- // it is created on connect, which can complete after this pool is
- // built; a null dispatcher (never connected) leaves the site's own
- // LOG line as the only announcement, same as before this sink.
+ // other SenderError. Two kinds arrive -- the data-loss report when a
+ // drainer abandons a slot, and the non-terminal faults its drain loop
+ // rides out (above all a credential the token provider cannot supply,
+ // which nothing else would ever surface). The dispatcher field is read
+ // lazily because it is created on connect, which can complete after
+ // this pool is built; a null dispatcher (never connected) leaves the
+ // site's own LOG line as the only announcement, same as before this sink.
drainerPool.setErrorSink(err -> {
SenderErrorDispatcher d = errorDispatcher;
if (d != null) {
@@ -2965,11 +3142,6 @@ public static int effectiveConnectTimeoutMs(boolean background, int configuredMs
return background && configuredMs <= 0 ? DEFAULT_BACKGROUND_CONNECT_TIMEOUT_MS : configuredMs;
}
- /**
- * Builds the per-attempt WebSocket client for {@link #buildAndConnect}.
- * Production path delegates to {@link WebSocketClientFactory}; tests may
- * install {@link #clientFactoryOverride} to substitute a stub.
- */
/**
* Best-effort close for a client being abandoned because a JVM Error is
* about to be rethrown: under OOM {@code close()} itself can throw, and a
@@ -2984,6 +3156,11 @@ private static void closeQuietlyOnError(WebSocketClient client) {
}
}
+ /**
+ * Builds the per-attempt WebSocket client for {@link #buildAndConnect}.
+ * Production path delegates to {@link WebSocketClientFactory}; tests may
+ * install {@link #clientFactoryOverride} to substitute a stub.
+ */
private WebSocketClient newWebSocketClient() {
java.util.function.Supplier override = clientFactoryOverride;
if (override != null) {
@@ -3125,6 +3302,55 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo
HttpClientException terminalUpgradeError = null;
QwpIngressRoleRejectedException lastRoleReject = null;
Endpoint lastEndpoint = null;
+ // Honor a close/stop that raced this (re)connect before doing any work - a token pull can make a
+ // blocking network call - mirroring the per-endpoint check at the top of the walk below. Use the
+ // context's abort gate, not the foreground loop's state: a background drainer must still be able to
+ // (re)connect during the sender's close sequence, when the foreground loop is already stopped.
+ if (ctx.isAborted()) {
+ throw new LineSenderException(ctx.abortMessage());
+ }
+ // Resolve the Authorization header ONCE per (re)connect round, before the endpoint walk. For an
+ // httpTokenProvider this queries the provider a single time - a fresh token per handshake round, as the
+ // javadoc documents - NOT once per endpoint: a token-provider failure is cluster-wide (a failed silent
+ // refresh, or not signed in), not a per-endpoint transport fault, so re-querying it per endpoint would
+ // hammer the token endpoint with the same dead credential and mislabel the failure as "all endpoints
+ // unreachable". A throw here is wrapped as QwpCredentialUnavailableException (below): the
+ // foreground/SYNC initial connect unwraps it and fails fast with the provider's own message, while the
+ // running background drainer treats it as a transient outage and retries it indefinitely with capped
+ // backoff (never bounded by the reconnect budget, never terminal), so even a persistent credential
+ // outage keeps the buffered rows in store-and-forward rather than terminating the sender (Invariant B).
+ // Mirrors QwpQueryClient, which likewise resolves the credential once before its endpoint walk.
+ // Publish this thread as being inside the pull BEFORE making it, then re-check cancellation, exactly
+ // as the per-endpoint connect below does with the WebSocketClient. The pull is the one blocking call
+ // in the walk that cancel()'s closeTraffic() cannot reach - it runs caller-supplied HttpTokenProvider
+ // code - and it can outlast close()'s shutdown budget, so cancel() breaks it with an interrupt
+ // instead. Skipped on the foreground path, where cancellation is null and close() is not racing us.
+ if (cancellation != null) {
+ cancellation.publishCredentialPull(Thread.currentThread());
+ if (cancellation.isCancelled()) {
+ cancellation.clearCredentialPull();
+ throw new LineSenderException(ctx.abortMessage());
+ }
+ }
+ final String authHeader;
+ try {
+ authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get();
+ } catch (RuntimeException e) {
+ // Tag the failure CLASS so each context applies the right policy: a credential we cannot acquire is
+ // not a transport outage. A foreground/SYNC connect unwraps this and rethrows the provider's own
+ // exception, so build() surfaces the provider's error directly rather than an internal wrapper. The
+ // running background drainer, by contrast, treats it as a transient outage and retries it
+ // indefinitely under Invariant B -- never bounding it by the reconnect budget, never latching a
+ // terminal -- so a recoverable credential outage never drops a producer store-and-forward promised
+ // to keep alive.
+ throw new QwpCredentialUnavailableException(e);
+ } finally {
+ // Drop the marker as soon as the pull returns, so a later cancel() cannot interrupt this thread
+ // at an arbitrary point in the walk. Mirrors ConnectCancellation.clear() for the in-flight client.
+ if (cancellation != null) {
+ cancellation.clearCredentialPull();
+ }
+ }
while (true) {
if (ctx.isAborted()) {
throw new LineSenderException(ctx.abortMessage());
@@ -3164,7 +3390,10 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo
}
newClient.connect(ep.host, ep.port);
int upgradeTimeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE);
- newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authorizationHeader);
+ // Present the header resolved once above for this handshake. On a failover to a later endpoint
+ // the same round's token is reused (a token is cluster-wide), so the provider is queried once
+ // per reconnect round, not once per endpoint.
+ newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authHeader);
if (cancellation != null) {
// connect()+upgrade() completed: this client is no longer
// blocking, so drop it from the in-flight handle before it
@@ -3798,6 +4027,11 @@ private void ensureConnected() {
default:
try {
client = reconnectFactory.reconnect();
+ } catch (QwpCredentialUnavailableException e) {
+ // The caller configured the token provider, so surface the provider's own exception (and
+ // its message) rather than the internal marker: a credential failure on a foreground
+ // connect is the caller's to see, not a transport-shaped wrapper.
+ throw e.providerFailure();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
@@ -4377,6 +4611,29 @@ private void disableDeltaDict(Throwable cause) {
cause);
}
+ /**
+ * On-wire byte cost of one symbol-dictionary entry, exactly as
+ * {@code NativeBufferWriter.putString} writes it: {@code [varint utf8Len][utf8]}.
+ * Both of that method's branches (the ASCII fast path, which reserves
+ * {@code varintSize(charLen) == varintSize(utf8Len)}, and the two-pass fallback)
+ * produce this size, so the chunker below sizes frames against the same
+ * arithmetic the encoder will use rather than an independent estimate.
+ */
+ private int dictionaryEntryWireBytes(int id) {
+ int utf8Len = NativeBufferWriter.utf8Length(globalSymbolDictionary.getSymbol(id));
+ return NativeBufferWriter.varintSize(utf8Len) + utf8Len;
+ }
+
+ /**
+ * Whether the {@code Authorization} header is re-derived on every handshake from a caller-supplied
+ * token provider, rather than being a constant captured once. A rotating credential makes a
+ * {@code 401} potentially recoverable, which the orphan drainer's terminal policy depends on; see
+ * {@link #fixedAuthHeader(String)}.
+ */
+ private boolean hasDynamicCredential() {
+ return authorizationHeaderSupplier != null && !(authorizationHeaderSupplier instanceof FixedAuthHeader);
+ }
+
/**
* Writes the ids the surviving frames contributed above the persisted prefix back
* into {@code .symbol-dict}, immediately, before any new frame can be published.
@@ -4401,19 +4658,6 @@ private void disableDeltaDict(Throwable cause) {
*
* Healing here, eagerly and in full, restores the invariant before the window opens.
*/
- /**
- * On-wire byte cost of one symbol-dictionary entry, exactly as
- * {@code NativeBufferWriter.putString} writes it: {@code [varint utf8Len][utf8]}.
- * Both of that method's branches (the ASCII fast path, which reserves
- * {@code varintSize(charLen) == varintSize(utf8Len)}, and the two-pass fallback)
- * produce this size, so the chunker below sizes frames against the same
- * arithmetic the encoder will use rather than an independent estimate.
- */
- private int dictionaryEntryWireBytes(int id) {
- int utf8Len = NativeBufferWriter.utf8Length(globalSymbolDictionary.getSymbol(id));
- return NativeBufferWriter.varintSize(utf8Len) + utf8Len;
- }
-
private void healPersistedDictionary(PersistedSymbolDict pd) {
if (pd == null || !deltaDictEnabled) {
return;
@@ -5045,7 +5289,10 @@ private void validateTableName(CharSequence name) {
if (name.length() > MAX_TABLE_NAME_LENGTH) {
throw new LineSenderException("table name too long [maxLength=" + MAX_TABLE_NAME_LENGTH + "]");
}
- throw new LineSenderException("table name contains illegal characters: " + name);
+ // sanitize the rejected name before it reaches the message (and any log/terminal): a name that
+ // failed validation can carry BOM/bidi/zero-width/control chars that would otherwise reorder, hide
+ // or forge what a human reads, matching how the ILP name/error render escapes untrusted text
+ throw new LineSenderException("table name contains illegal characters: ").putAsPrintable(name);
}
}
@@ -5059,6 +5306,24 @@ public Endpoint(String host, int port) {
}
}
+ /**
+ * A constant {@code Authorization} header value. Its identity as a type - not the value it yields - is
+ * what {@link #hasDynamicCredential()} reads, so the drainer can apply the right terminal policy to a
+ * {@code 401}. See {@link #fixedAuthHeader(String)}.
+ */
+ private static final class FixedAuthHeader implements Supplier {
+ private final String header;
+
+ private FixedAuthHeader(String header) {
+ this.header = header;
+ }
+
+ @Override
+ public String get() {
+ return header;
+ }
+ }
+
private final class ReconnectSupplier implements CursorWebSocketSendLoop.ReconnectFactory {
/**
* Optional caller-owned liveness gate. {@code null} means this factory
@@ -5086,6 +5351,11 @@ String abortMessage() {
return abortCheck != null ? abortMessage : "sender closed during connect";
}
+ @Override
+ public boolean hasDynamicCredential() {
+ return QwpWebSocketSender.this.hasDynamicCredential();
+ }
+
/**
* True when this factory serves a background drainer. Background
* connects share buildAndConnect's endpoint walk and hostTracker
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java
index a9b5545ed..084cdddf8 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java
@@ -29,6 +29,7 @@
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
+import io.questdb.client.cutlass.qwp.client.QwpCredentialUnavailableException;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
@@ -92,6 +93,70 @@ public final class BackgroundDrainer implements Runnable {
* cluster-wide misconfig hang the drainer forever.
*/
public static final int DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS = 16;
+ /**
+ * Attempt threshold for {@code 401}/{@code 403} rejections an orphan drainer rides out before it
+ * may quarantine the slot, when - and only when - the credential is a ROTATING one
+ * ({@link CursorWebSocketSendLoop.ReconnectFactory#hasDynamicCredential()}). Against a constant
+ * credential a rejection stays terminal on the first sweep, as it always was.
+ *
+ * The attempt threshold is necessary but not sufficient: the rejection must also PERSIST for at least
+ * the dwell returned by {@link #dynamicCredentialAuthDwellNanos(long)} - {@code
+ * reconnectMaxDurationMillis}, clamped so an unbounded configuration cannot disable the escalation -
+ * measured from the first rejection of the current uninterrupted run. A transient in between (role
+ * reject, transport, credential-unavailable) restarts that measurement, because time the drainer spent
+ * unable to reach anyone is not time the credential spent rejected. This wall-clock floor gives IdP
+ * signing-key and resource-server JWKS caches time to converge even when capped backoff can accumulate
+ * six attempts in only a few seconds. A credential that stays rejected still reaches a human after both
+ * thresholds are met rather than pinning the slot and a drainer-pool worker forever.
+ * Note it cannot repair a PERSISTENT clock skew: the provider keeps serving the same cached token, so
+ * those sweeps eventually exhaust both thresholds and quarantine, which is the right end state for a
+ * condition that is not healing.
+ */
+ public static final int DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS = 6;
+ /**
+ * Hard ceiling on rotating-credential {@code 401}/{@code 403} rejections within one no-ack-progress
+ * episode, whatever the dwell says.
+ *
+ * The dwell is a wall clock anchored at the first rejection of the current run, and the
+ * capability-gap, role-reject and transport arms all rewind that anchor - deliberately, so an
+ * unrelated outage cannot satisfy the floor for free. The attempt threshold beside it is only ever
+ * reset by real ack progress. That asymmetry is what the gate needs in the ordinary case and what
+ * breaks it in the pathological one: a cluster that ALTERNATES - reject, blip, reject, blip - rewinds
+ * the anchor before every rejection, so the elapsed dwell is always ~0, the AND can never be
+ * satisfied, and the ride-out never ends. The drainer then sweeps forever with no ack progress: no
+ * {@code .failed} sentinel, no {@code DATA_LOSS} report, the slot lock held, and one worker of a
+ * fixed-size {@link BackgroundDrainerPool} pinned for the life of the process - the exact outcome
+ * {@link #MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS} exists to prevent, reached by a different route.
+ *
+ * An attempt cap is the right backstop precisely because nothing rewinds it: it shares the attempt
+ * counter's episode scope, so it bounds the ride-out however the rejections are spaced. It is set far
+ * above {@link #DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS} on purpose - the dwell decides every
+ * case that is not pathological, and this only ever fires when the dwell has been rendered
+ * unsatisfiable.
+ */
+ public static final int MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_PER_EPISODE = 256;
+ /**
+ * Ceiling on the rotating-credential {@code 401}/{@code 403} wall-clock dwell, independent of the user
+ * knob it is otherwise derived from.
+ *
+ * The dwell is an AND with the attempt threshold - both must be exhausted before an orphan slot is
+ * quarantined - and it is taken from {@code reconnect_max_duration_millis}, which is validated only as
+ * {@code > 0} and whose documented way to ask a reconnect never to give up is {@code Long.MAX_VALUE}.
+ * {@code TimeUnit} saturates that, so the dwell conjunct became unsatisfiable and the ride-out never
+ * ended: the drainer swept forever, never wrote the {@code .failed} sentinel, never reported
+ * {@code DATA_LOSS}, and pinned the slot lock plus one worker of a FIXED-size
+ * {@link BackgroundDrainerPool} for the life of the process, starving every other orphan slot. The
+ * capability-gap gate below survives the same saturation because it is an OR; this gate cannot be an OR
+ * without losing the dwell floor that stops a healing credential being abandoned in the seconds capped
+ * backoff needs to spend six attempts, so it is clamped instead.
+ *
+ * Set to the DEFAULT reconnect budget rather than a new figure: five minutes is already what this design
+ * calls a settle budget, and a larger configured value is a statement about reconnect persistence, not
+ * about how long a credential failure may stay hidden from an operator. A smaller configured value is
+ * honoured as-is, so tuning down still works.
+ */
+ public static final long MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS =
+ CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS;
private static final Logger LOG = LoggerFactory.getLogger(BackgroundDrainer.class);
/** How often to wake and re-check ackedFsn vs target. */
private static final long POLL_NANOS = 50_000_000L; // 50 ms
@@ -113,6 +178,36 @@ public final class BackgroundDrainer implements Runnable {
private final long sfMaxTotalBytes;
private final String slotPath;
private final long syncIntervalNanos;
+ /**
+ * Escalation counters for the two bounded ride-outs, held per DRAIN rather than per call, plus the
+ * ack watermark that scopes them.
+ *
+ * The counters were locals of {@link #connectWithDurableAckRetry()}, which {@link #run()} re-enters
+ * after every mid-drain terminal - so each recycle refilled the budget it is supposed to spend. A
+ * cluster that flaps (connect accepted, drop, {@code 401}, recycle, repeat) looped forever with no ack
+ * progress: the escalation never arrived, the slot lock was never released, and one of {@code
+ * max_background_drainers} workers - four by default - stayed pinned, so four such slots starve every
+ * other orphan slot of a drainer. No data is lost, but none is delivered either, and no operator ever
+ * sees the quarantine.
+ *
+ * "No ack progress" is the actual condition, and {@code ackProgressWatermark} is what measures it.
+ * Spanning the whole drain instead over-counts in the opposite direction: a drain that connects,
+ * DELIVERS, then meets a later gap accumulates both runs toward one threshold, so 16 sweeps that were
+ * never consecutive quarantine a slot the cluster is still draining - abandoning replayable rows behind
+ * a {@code .failed} sentinel nothing in production clears. Both terminals are documented as CONSECUTIVE
+ * sweeps, so {@link #noteAckProgress(long)} ends the episode the moment the wire delivers anything: a
+ * durable ack past this watermark is positive proof the cluster accepted us, which is strictly stronger
+ * evidence than the role-reject and transport arms that already reset these. A flap that delivers
+ * nothing never advances the watermark and still escalates on schedule.
+ *
+ * The wall-clock helpers beside them ({@code capabilityGapElapsedNanos}, {@code lastCapabilityGapNanos})
+ * deliberately stay per-call: they measure an UNINTERRUPTED run, and a successful connect plus a drain
+ * is an interruption, so a fresh call should start their accounting over.
+ */
+ private long ackProgressWatermark = Long.MIN_VALUE;
+ private int capabilityGapAttempts;
+ private int dynamicCredentialAuthAttempts;
+ private long firstDynamicCredentialAuthFailureNanos;
/** Latest known {@code engine.ackedFsn()}; published for visibility. */
private volatile long ackedFsn = -1L;
/**
@@ -121,10 +216,13 @@ public final class BackgroundDrainer implements Runnable {
* reference an already-closed engine once the drain ends.
*/
private volatile CursorSendEngine engineForTesting;
- // Sink for SenderError.dataLoss reports fired when this drainer
- // permanently abandons a slot behind a .failed sentinel. Volatile for the
- // same reason as `listener`: applied by the pool at submit time, read on
- // the drainer thread. Null means the abandonment is announced only via
+ // Sink for this drainer's SenderError reports. Two feeds: the dataLoss fired
+ // when it permanently abandons a slot behind a .failed sentinel, and the
+ // non-TERMINAL reports of the drain loop itself -- an unobtainable credential
+ // above all, plus the server rejections it replays through -- which run()
+ // forwards by handing the loop a SenderErrorDispatcher over this sink.
+ // Volatile for the same reason as `listener`: applied by the pool at submit
+ // time, read on the drainer thread. Null means both are announced only via
// LOG -- a NOP for apps without an slf4j binding -- which is exactly the
// silence this sink exists to break.
private volatile SenderErrorHandler errorSink;
@@ -253,6 +351,23 @@ public BackgroundDrainer() {
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, 0L);
}
+ /**
+ * The effective wall-clock dwell the rotating-credential {@code 401} ride-out uses: the configured
+ * reconnect budget, clamped to {@link #MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS} so that an
+ * "effectively unbounded" configuration cannot disable the escalation entirely.
+ *
+ * Public and pure so the clamp can be asserted directly. The alternative - proving it end to end - means
+ * waiting out the ceiling, which is five minutes of wall clock in a test.
+ *
+ * @param reconnectMaxDurationMillis the configured {@code reconnect_max_duration_millis}
+ * @return the dwell in nanoseconds, always finite
+ */
+ public static long dynamicCredentialAuthDwellNanos(long reconnectMaxDurationMillis) {
+ return Math.min(
+ TimeUnit.MILLISECONDS.toNanos(reconnectMaxDurationMillis),
+ TimeUnit.MILLISECONDS.toNanos(MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS));
+ }
+
/**
* Budgeted connect with retry on whole-cluster durable-ack unavailability:
* the initial connect, and re-entered from {@link #run()} whenever a
@@ -281,8 +396,9 @@ public BackgroundDrainer() {
* transport error -- are retried indefinitely (Invariant B) and never
* consume the budget. Either transient restarts the attempt count and wall
* clock so only uninterrupted capability-gap sweeps can escalate.
- * Genuine terminals (auth failure, non-421 upgrade reject) preserve
- * the original behavior: mark failed, exit.
+ * Genuine terminals (a constant-credential auth failure, a rotating-credential
+ * auth failure that exhausts both its attempt threshold and wall-clock floor,
+ * or a non-421 upgrade reject) mark the slot failed and exit.
*
* @return a fresh durable-ack-capable client, or {@code null} if
* {@link #outcome} has been set to FAILED or STOPPED
@@ -301,10 +417,27 @@ public WebSocketClient connectWithDurableAckRetry() {
// intervening role or transport state resets the episode: after the
// cluster leaves the capability-gap state, later gaps must establish a
// fresh consecutive run before quarantine is permitted.
- int capabilityGapAttempts = 0;
+ // (capabilityGapAttempts is a field - see its declaration)
+ // 401/403 sweeps ridden out so far, counted only for a ROTATING credential (see
+ // DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS). Not reset by the transient arms below, so a
+ // credential alternating rejected/unreachable inside ONE connect attempt cannot refill it and
+ // stall the quarantine an operator needs to see - unlike the capability-gap episode, and unlike
+ // the dwell anchor beside it, which does restart because it measures persistence rather than
+ // count.
+ //
+ // It is a FIELD, so a mid-drain recycle cannot refill it - see its declaration.
+ // (dynamicCredentialAuthAttempts is a field)
+ // The rotating-auth wall-clock floor is anchored at the first 401/403 of the CURRENT run of
+ // rejections: a transient class in between (role reject, transport, credential-unavailable) restarts
+ // it, because the dwell measures how long the rejection persisted, not how long the drainer has been
+ // running. A recycle is NOT such an interruption for this anchor: it is a field, so the dwell spans
+ // recycles, which is what lets a flapping credential reach the escalation at all - the attempt
+ // threshold alone cannot, because the gate is an AND. The attempt threshold, unlike this, never
+ // resets during the drain. A zero value means no rejection has been observed.
+ // (firstDynamicCredentialAuthFailureNanos is a field)
// Wall-clock time accumulated across uninterrupted gap-to-gap
// intervals of the current episode; escalates once it reaches
- // capabilityGapBudgetNanos (or the attempt cap fires first).
+ // reconnectBudgetNanos (or the attempt cap fires first).
long capabilityGapElapsedNanos = 0L;
// Timestamp of the previous capability-gap sweep; 0 = the next gap
// charges nothing because a fresh episode is starting.
@@ -318,8 +451,13 @@ public WebSocketClient connectWithDurableAckRetry() {
// more tolerance would buy exactly none. TimeUnit clamps at Long.MAX_VALUE, which
// is the intended "effectively unbounded". CursorWebSocketSendLoop's dwell
// conversion guards the same way for the same reason.
- final long capabilityGapBudgetNanos =
+ final long reconnectBudgetNanos =
TimeUnit.MILLISECONDS.toNanos(reconnectMaxDurationMillis);
+ // The rotating-401 gate needs its own, clamped copy: it is an AND, so an unbounded value there is
+ // not "effectively unbounded" but "never escalates". The capability-gap gate below keeps the raw
+ // budget - it is an OR, so its attempt cap fires regardless.
+ final long dynamicCredentialAuthDwellNanos =
+ dynamicCredentialAuthDwellNanos(reconnectMaxDurationMillis);
// Observability-only counter for the transient all-replica window;
// never consulted for escalation (Invariant B).
int roleRejectAttempts = 0;
@@ -338,17 +476,74 @@ public WebSocketClient connectWithDurableAckRetry() {
try {
return clientFactory.reconnect();
} catch (QwpAuthFailedException | WebSocketUpgradeException e) {
- // Genuinely non-retriable across the cluster (auth 401/403, or a
- // non-421 upgrade reject): waiting will not fix it, so quarantine
- // immediately under the orphan reconnect policy.
- String msg = e.getMessage();
- LOG.error("drainer terminal upgrade/auth error for slot {}: {}", slotPath, msg);
- lastErrorMessage = msg;
- String reason = "auth/upgrade: " + msg;
- OrphanScanner.markFailed(slotPath, reason);
- dispatchDataLoss(reason);
- outcome = DrainOutcome.FAILED;
- return null;
+ // A non-421 upgrade reject, and a 401/403 against a CONSTANT credential, are genuinely
+ // non-retriable across the cluster: waiting will not fix them, so quarantine immediately
+ // under the orphan reconnect policy.
+ //
+ // A 401/403 against a ROTATING credential is a different condition. The header is
+ // re-derived from the caller's token provider on every sweep, so the rejection can be a
+ // window that heals itself, and the next sweep carries a freshly pulled token. Quarantining
+ // on the first one would permanently abandon replayable data - nothing in production clears
+ // the .failed sentinel - on a fault that repairs itself. Require BOTH enough rejection
+ // attempts and a minimum wall-clock dwell before quarantine: capped backoff can otherwise spend
+ // the attempt threshold in seconds, far sooner than IdP signing-key/JWKS caches commonly
+ // converge.
+ boolean retryDynamicCredentialAuth = false;
+ long dynamicCredentialAuthElapsedNanos = 0L;
+ if (e instanceof QwpAuthFailedException && clientFactory.hasDynamicCredential()) {
+ dynamicCredentialAuthAttempts++;
+ long now = System.nanoTime();
+ if (firstDynamicCredentialAuthFailureNanos == 0L) {
+ firstDynamicCredentialAuthFailureNanos = now;
+ }
+ dynamicCredentialAuthElapsedNanos = now - firstDynamicCredentialAuthFailureNanos;
+ // Both thresholds still gate the quarantine - a healing credential is never abandoned
+ // early - but the dwell is the CLAMPED one, so a saturated reconnect_max_duration_millis
+ // cannot make the second conjunct unsatisfiable and turn "ride it out" into "never
+ // escalate". See MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS.
+ // The AND of the two thresholds, under a cap that nothing can rewind. The transient
+ // arms below deliberately restart the dwell anchor, so an ALTERNATING cluster -
+ // reject, blip, reject, blip - re-anchors before every rejection and leaves the
+ // elapsed dwell permanently at ~0, making the AND unsatisfiable and the ride-out
+ // endless. The attempt counter shares this episode's scope (only ack progress clears
+ // it), so capping on it bounds the ride-out however the rejections are spaced while
+ // leaving the dwell to decide every non-pathological case. See
+ // MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_PER_EPISODE.
+ retryDynamicCredentialAuth =
+ (dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS
+ || dynamicCredentialAuthElapsedNanos < dynamicCredentialAuthDwellNanos)
+ && dynamicCredentialAuthAttempts < MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_PER_EPISODE;
+ }
+ if (retryDynamicCredentialAuth) {
+ lastErrorMessage = e.getMessage();
+ // An auth rejection is unrelated to any open durable-ack episode: we reached a node and
+ // it refused the credential, which says nothing about its batch cap. Restart the episode
+ // so a later gap gets its full settle budget, exactly as the transport arm below does.
+ capabilityGapAttempts = 0;
+ capabilityGapElapsedNanos = 0L;
+ lastCapabilityGapNanos = 0L;
+ // the CLAMPED dwell, which is the one the gate above just applied. Reporting the raw
+ // reconnect_max_duration_millis here was misleading exactly where the clamp matters: a
+ // saturated budget rendered as "dwell 12ms/9223372036854775807ms", telling an operator the
+ // ride-out would never end when it was in fact bounded at the ceiling.
+ LOG.warn("drainer slot {} attempt {} (threshold {}, dwell {}ms/{}ms): "
+ + "the rotating credential was rejected ({}); retrying with a freshly pulled "
+ + "token after backoff",
+ slotPath, dynamicCredentialAuthAttempts,
+ DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS,
+ dynamicCredentialAuthElapsedNanos / 1_000_000L,
+ dynamicCredentialAuthDwellNanos / 1_000_000L, e.getMessage());
+ // fall through to the shared capped-backoff block
+ } else {
+ String msg = e.getMessage();
+ LOG.error("drainer terminal upgrade/auth error for slot {}: {}", slotPath, msg);
+ lastErrorMessage = msg;
+ String reason = "auth/upgrade: " + msg;
+ OrphanScanner.markFailed(slotPath, reason);
+ dispatchDataLoss(reason);
+ outcome = DrainOutcome.FAILED;
+ return null;
+ }
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
// INVARIANT B: every reachable endpoint is a REPLICA right now.
// A replica is promotable and a primary will reappear, so this is
@@ -365,6 +560,16 @@ public WebSocketClient connectWithDurableAckRetry() {
capabilityGapAttempts = 0;
capabilityGapElapsedNanos = 0L;
lastCapabilityGapNanos = 0L;
+ // The rotating-401 dwell measures how long the REJECTION has persisted, so time spent in
+ // an unrelated state is not part of it. Restart its anchor for the same reason the
+ // capability-gap episode restarts above: without this, a 401, then an outage outlasting the
+ // dwell, then a sixth rejection satisfies both thresholds at once and quarantines a slot on
+ // a credential that was only rejected for seconds - abandoning replayable rows behind a
+ // .failed sentinel nothing in production clears. The attempt counter deliberately does NOT
+ // reset (a credential alternating rejected/unreachable must not refill it indefinitely), and
+ // the clamped dwell (MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS) keeps the escalation reachable
+ // regardless of what reconnect_max_duration_millis is set to.
+ firstDynamicCredentialAuthFailureNanos = 0L;
BackgroundDrainerListener l = listener;
if (l != null) {
try {
@@ -388,6 +593,14 @@ public WebSocketClient connectWithDurableAckRetry() {
// stays terminal for the drainer -- give the cluster a bounded
// settle budget (rolling upgrade), then quarantine the slot.
capabilityGapAttempts++;
+ // Symmetry with the arm above, which restarts the capability-gap episode because an auth
+ // rejection says nothing about a node's batch cap: a capability gap says nothing about the
+ // credential either. We reached a node and it answered - it simply cannot do durable ack -
+ // so this is not time the credential spent REJECTED, and charging it to the rotating-401
+ // dwell would let a rolling upgrade satisfy that floor for free. The settle budget below can
+ // legitimately run for the whole reconnect budget, which is exactly the span the dwell is
+ // meant to require of an uninterrupted rejection.
+ firstDynamicCredentialAuthFailureNanos = 0L;
long now = System.nanoTime();
if (lastCapabilityGapNanos != 0L) {
// Charge only the interval since the PREVIOUS gap sweep,
@@ -399,7 +612,7 @@ public WebSocketClient connectWithDurableAckRetry() {
lastCapabilityGapNanos = now;
long elapsedMs = capabilityGapElapsedNanos / 1_000_000L;
boolean exhausted = capabilityGapAttempts >= DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS
- || capabilityGapElapsedNanos >= capabilityGapBudgetNanos;
+ || capabilityGapElapsedNanos >= reconnectBudgetNanos;
BackgroundDrainerListener l = listener;
if (exhausted) {
LOG.error("drainer giving up on slot {} after {} durable-ack-mismatch attempts ({}ms): {}",
@@ -455,6 +668,11 @@ public WebSocketClient connectWithDurableAckRetry() {
// WebSocketUpgradeException) and is intentionally retried under
// Invariant B -- but it is NOT a transport outage, so log it
// truthfully below rather than mislabelling it "cluster unreachable".
+ // The same holds for a credential the client cannot ACQUIRE
+ // (QwpCredentialUnavailableException extends LineSenderException, so it
+ // matches none of the typed arms above): retried indefinitely here, for
+ // the reason CursorWebSocketSendLoop's matching arm spells out, but
+ // named for what it is.
lastErrorMessage = t.getMessage();
// This unrelated state breaks the consecutive capability-gap
// run. Restart both halves of the settle budget so a later gap
@@ -462,6 +680,16 @@ public WebSocketClient connectWithDurableAckRetry() {
capabilityGapAttempts = 0;
capabilityGapElapsedNanos = 0L;
lastCapabilityGapNanos = 0L;
+ // The rotating-401 dwell measures how long the REJECTION has persisted, so time spent in
+ // an unrelated state is not part of it. Restart its anchor for the same reason the
+ // capability-gap episode restarts above: without this, a 401, then an outage outlasting the
+ // dwell, then a sixth rejection satisfies both thresholds at once and quarantines a slot on
+ // a credential that was only rejected for seconds - abandoning replayable rows behind a
+ // .failed sentinel nothing in production clears. The attempt counter deliberately does NOT
+ // reset (a credential alternating rejected/unreachable must not refill it indefinitely), and
+ // the clamped dwell (MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS) keeps the escalation reachable
+ // regardless of what reconnect_max_duration_millis is set to.
+ firstDynamicCredentialAuthFailureNanos = 0L;
long nowWarn = System.nanoTime();
if (nowWarn - lastTransportWarnNanos >= 5_000_000_000L) {
if (t instanceof QwpVersionMismatchException) {
@@ -478,6 +706,18 @@ public WebSocketClient connectWithDurableAckRetry() {
+ "QWP protocol version ({}); retrying (rolling-upgrade window) -- "
+ "if this persists the client is version-incompatible with the cluster",
slotPath, t.getMessage());
+ } else if (t instanceof QwpCredentialUnavailableException) {
+ // Nothing was attempted on the wire: the configured token provider
+ // threw instead of handing over a credential (a failed silent
+ // refresh, a revoked or expired refresh token, an unreachable IdP,
+ // or an interactive sign-in not finished yet). The cluster may be
+ // perfectly healthy, so "cluster unreachable" sends the operator
+ // after a network fault that does not exist while the slot's rows
+ // sit undrained. Point at the credential instead.
+ LOG.warn("drainer slot {}: the token provider failed to supply a credential ({}); "
+ + "retrying after backoff -- the slot stays un-drained until a token "
+ + "is available",
+ slotPath, t.getMessage());
} else {
LOG.warn("drainer slot {}: cluster unreachable ({}), retrying after backoff",
slotPath, t.getMessage());
@@ -497,7 +737,7 @@ public WebSocketClient connectWithDurableAckRetry() {
long sleepMillis = backoffMillis + jitter;
if (boundedByBudget) {
sleepMillis = Math.min(sleepMillis,
- Math.max(0L, (capabilityGapBudgetNanos - capabilityGapElapsedNanos) / 1_000_000L));
+ Math.max(0L, (reconnectBudgetNanos - capabilityGapElapsedNanos) / 1_000_000L));
}
if (sleepMillis > 0L && !stopRequested) {
long parkDeadlineNanos = System.nanoTime() + sleepMillis * 1_000_000L;
@@ -547,6 +787,27 @@ public boolean isStopRequested() {
return stopRequested;
}
+ /**
+ * Pre-ages the rotating-credential rejection anchor so a test can reach the
+ * {@link #MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS} ceiling without waiting it out in real time.
+ *
+ * The clamp on the connect loop's dwell is otherwise unobservable end to end. Every other drainer test
+ * configures a dwell far below the ceiling, where {@code Math.min} returns its first argument either
+ * way, so the loop reverting to the raw budget leaves them all green - while a saturated
+ * {@code reconnect_max_duration_millis} makes the gate's second conjunct unsatisfiable and an orphan
+ * drainer sweeps forever, pinning the slot lock and one pool worker. Proving it honestly instead means
+ * waiting out the ceiling, which is five minutes of wall clock per run.
+ *
+ * @param ageNanos how long ago the current run of rejections should appear to have begun
+ */
+ @TestOnly
+ public void ageDynamicCredentialAuthAnchorForTesting(long ageNanos) {
+ long anchor = System.nanoTime() - ageNanos;
+ // 0 is the "no rejection observed yet" sentinel, and the connect loop overwrites it on the next
+ // rejection - which would silently undo the ageing and make the test pass for the wrong reason.
+ firstDynamicCredentialAuthFailureNanos = anchor != 0L ? anchor : 1L;
+ }
+
/**
* Engine this drainer constructed, or {@code null} until {@link #run()}
* gets past engine construction. The reference outlives the drain, so
@@ -618,6 +879,31 @@ private void dispatchDataLoss(String reason) {
}
}
+ /**
+ * Ends any open escalation episode once the wire has durably acked something new. Both bounded
+ * ride-outs are documented as CONSECUTIVE sweeps, and a durable ack past the watermark is the
+ * cluster accepting this drainer - stronger evidence than a role reject or a transport error, both
+ * of which already reset these counters. Without it the two per-drain counters span every session of
+ * the drain, so a rolling upgrade that delivers between two gap windows accumulates both toward one
+ * threshold and quarantines a slot that was still draining.
+ *
+ * Runs on the drainer thread only, from {@link #run()}'s poll loop, so the plain field reads and
+ * writes need no synchronisation. Deliberately NOT called from {@link #connectWithDurableAckRetry()}:
+ * a successful connect on its own delivers nothing, and treating it as progress would restore the
+ * per-call refill that let a flapping cluster recycle forever.
+ *
+ * @param acked the engine's current durable-ack watermark
+ */
+ private void noteAckProgress(long acked) {
+ if (acked <= ackProgressWatermark) {
+ return;
+ }
+ ackProgressWatermark = acked;
+ capabilityGapAttempts = 0;
+ dynamicCredentialAuthAttempts = 0;
+ firstDynamicCredentialAuthFailureNanos = 0L;
+ }
+
@Override
public void run() {
runnerThread = Thread.currentThread();
@@ -625,6 +911,12 @@ public void run() {
CursorSendEngine engine = null;
WebSocketClient client = null;
CursorWebSocketSendLoop loop = null;
+ // Async delivery arm for the drain loop's own SenderError reports. Built
+ // only when a sink is installed, and only once per run() -- it outlives
+ // the mid-drain loop recycles below, which would otherwise churn a thread
+ // per wire session. Closed by the finally, after loop.close(), so errors
+ // dispatched during the loop's shutdown still reach the sink.
+ SenderErrorDispatcher loopErrorDispatcher = null;
try {
// Scanner results are only snapshots. Serialize adoption against
// a producer's close -> quarantine rename -> fresh-slot recreate
@@ -762,12 +1054,42 @@ public void run() {
outcome = DrainOutcome.SUCCESS;
return;
}
+ // Seed the progress watermark from what a previous run already durably acked, so only acks
+ // THIS drain earns count as progress. Seeding from the -1 field default would make the first
+ // poll of a partially-drained slot read as progress and hand back a budget the initial connect
+ // had legitimately spent.
+ ackProgressWatermark = engine.ackedFsn();
client = connectWithDurableAckRetry();
if (client == null) {
// outcome already set (FAILED or STOPPED); markFailed sentinel
// already dropped on the FAILED path.
return;
}
+ // Read the sink once: like `listener` it is volatile because the pool
+ // applies it at submit time and it is consumed on the drainer thread.
+ SenderErrorHandler sink = errorSink;
+ if (sink != null) {
+ // The I/O thread must never run the sink inline -- it is caller-supplied
+ // code and may block -- so it reaches the sink through the same bounded,
+ // drop-oldest, off-thread arm the foreground sender uses.
+ //
+ // TERMINAL is dropped on the way through: on an ORPHAN loop it does not
+ // mean what it means to a foreground producer. It is the loop handing the
+ // slot back to this drainer, which then decides -- ride the fault out and
+ // finish the drain, or quarantine and report the abandonment itself with
+ // dispatchDataLoss. Forwarding it would announce a dead producer for a
+ // rotating credential the very next sweep accepts, and would double-report
+ // the quarantine the drainer already names. Everything the loop rides out
+ // (RETRIABLE / RETRIABLE_OTHER) has no such owner and is forwarded verbatim.
+ loopErrorDispatcher = new SenderErrorDispatcher(
+ err -> {
+ if (err.getAppliedPolicy() != SenderError.Policy.TERMINAL) {
+ sink.onError(err);
+ }
+ },
+ SenderErrorDispatcher.DEFAULT_CAPACITY, "qdb-sf-drainer-error-dispatcher");
+ }
+
// One iteration per wire session. Re-entered ONLY when a mid-drain
// reconnect sweep hit a durable-ack CAPABILITY gap: that is the
// exact rolling-upgrade condition the settle budget in
@@ -790,10 +1112,22 @@ public void run() {
poisonMinEscalationWindowMillis,
catchUpCapGapMinEscalationWindowMillis,
CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN);
+ // Without this the loop's ridden-out reports -- above all
+ // "credential-unavailable", the one endpoint-policy failure an ORPHAN
+ // loop retries rather than latching -- are dispatched into a null, and
+ // the outage is announced only by a throttled slf4j WARN, which is a
+ // NOP in an app with no binding configured. The foreground sender wires
+ // the same arm (QwpWebSocketSender.buildAndConnect /
+ // startCursorSendLoop); an orphan drainer rides out the same faults and
+ // must be just as observable, or a revoked token reads as a disk-sizing
+ // problem once SF fills. Null when no sink is installed, which
+ // setErrorDispatcher accepts and dispatchError treats as before.
+ loop.setErrorDispatcher(loopErrorDispatcher);
loop.start();
while (!stopRequestedOrInterrupted()) {
long acked = engine.ackedFsn();
+ noteAckProgress(acked);
this.ackedFsn = acked;
if (acked >= target) {
outcome = DrainOutcome.SUCCESS;
@@ -814,17 +1148,31 @@ public void run() {
if (t.getCause() instanceof Error) {
throw (Error) t.getCause();
}
- if (loop.capabilityGapTerminal() != null) {
- // Capability gap mid-drain: recycle the wire, NOT
- // the slot. connectWithDurableAckRetry() owns the
- // episode budget (16 consecutive gap sweeps /
- // wall clock) and drops the sentinel itself if the
- // gap persists. The loop's own failed sweep is not
- // counted toward the fresh episode -- an off-by-one
- // that is immaterial at budget 16.
- LOG.warn("drainer slot {}: durable-ack capability gap "
- + "mid-drain ({}), re-entering settle budget",
- slotPath, t.getMessage());
+ if (loop.capabilityGapTerminal() != null || loop.authTerminal() != null) {
+ // Mid-drain RECOVERABLE terminal: recycle the wire, NOT
+ // the slot. connectWithDurableAckRetry() owns the matching
+ // bounded budget and drops the sentinel itself if the
+ // condition persists, so a fault that heals -- a rolling
+ // upgrade settling, or a rotating credential's next token
+ // being accepted -- never abandons replayable data on its
+ // first sweep. The loop's own failed sweep is not counted
+ // toward the fresh budget -- an off-by-one immaterial at
+ // either budget. Two classes route here:
+ // - capability gap: the 16 consecutive-sweep / wall-clock
+ // settle budget.
+ // - rotating-credential 401/403 (authTerminal): the
+ // DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS ride-out.
+ // Only an ORPHAN loop with a dynamic credential sets it;
+ // a constant credential stays fatal and quarantines below.
+ if (loop.authTerminal() != null) {
+ LOG.warn("drainer slot {}: rotating credential rejected mid-drain ({}), "
+ + "re-entering the rotating-401 ride-out",
+ slotPath, t.getMessage());
+ } else {
+ LOG.warn("drainer slot {}: durable-ack capability gap "
+ + "mid-drain ({}), re-entering settle budget",
+ slotPath, t.getMessage());
+ }
try {
loop.close();
} catch (Throwable closeFailure) {
@@ -940,6 +1288,19 @@ public void run() {
slotPath, e.getMessage());
}
}
+ if (loopErrorDispatcher != null) {
+ // After loop.close() so anything the I/O loop reported on its way
+ // out is still admitted, and before the sink can outlive this run.
+ // Safe on the failed-stop path above too: a still-live I/O thread's
+ // later offer() is rejected by the closed dispatcher rather than
+ // resurrecting its delivery thread.
+ try {
+ loopErrorDispatcher.close();
+ } catch (Throwable e) {
+ LOG.warn("drainer slot {}: error dispatcher close failed ({})",
+ slotPath, e.getMessage());
+ }
+ }
if (client != null && ioThreadStopped) {
// Skipped on a failed stop: the thread may be mid-send on
// this very client; ioLoop's finally closes the loop's
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java
index 5a66c3029..57b1544a1 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java
@@ -33,6 +33,7 @@
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.NativeBufferWriter;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
+import io.questdb.client.cutlass.qwp.client.QwpCredentialUnavailableException;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
@@ -86,16 +87,6 @@
*/
public final class CursorWebSocketSendLoop implements QuietCloseable {
- /**
- * Default cadence for the keepalive PING the I/O loop emits while
- * waiting on STATUS_DURABLE_ACK frames. See
- * {@link #sendDurableAckKeepaliveIfDue()} for the rationale: the OSS
- * server only flushes pending durable-ack frames on inbound recv
- * events, so an opted-in idle client has to prod it. {@code 200} ms
- * trades one PING per 200 ms per idle opted-in connection for
- * sub-second confirmation latency once the upload completes
- * server-side. {@code 0} or negative disables the keepalive entirely.
- */
/**
* Bounded-await backstop for {@link #close()}: the maximum time close()
* waits for the I/O thread to stop (count down {@code shutdownLatch})
@@ -119,6 +110,16 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
* rather than waiting it out.
*/
public static final long DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS = 30_000L;
+ /**
+ * Default cadence for the keepalive PING the I/O loop emits while
+ * waiting on STATUS_DURABLE_ACK frames. See
+ * {@link #sendDurableAckKeepaliveIfDue()} for the rationale: the OSS
+ * server only flushes pending durable-ack frames on inbound recv
+ * events, so an opted-in idle client has to prod it. {@code 200} ms
+ * trades one PING per 200 ms per idle opted-in connection for
+ * sub-second confirmation latency once the upload completes
+ * server-side. {@code 0} or negative disables the keepalive entirely.
+ */
public static final long DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS = 200L;
public static final long DEFAULT_PARK_NANOS = 50_000L; // 50us idle backoff
/**
@@ -437,6 +438,17 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// is why a revoked token surfaced to operators as "sf_max_total_bytes too small".
private volatile Throwable lastReconnectError;
private volatile Thread ioThread;
+ // Typed marker for a ROTATING-credential auth terminal (401/403): set (before the
+ // terminalError latch, so a checkError() caller that observes the latch is guaranteed to
+ // observe this marker too) when a mid-drain reconnect sweep on an ORPHAN drainer whose
+ // credential is dynamic (reconnectFactory.hasDynamicCredential()) threw QwpAuthFailedException.
+ // The orphan drainer consults it to route such a rejection into its bounded rotating-401
+ // ride-out (BackgroundDrainer.connectWithDurableAckRetry) instead of quarantining the slot on
+ // the first rejection: the header is re-derived from the token provider every attempt, so a 401
+ // can be a self-healing window that a freshly pulled token clears -- the same reasoning the
+ // capability-gap recycle rests on. A CONSTANT credential never sets it (it stays fatal), and
+ // foreground reconnects never set it either. Write-once alongside terminalError.
+ private volatile QwpAuthFailedException authTerminal;
// Typed marker for a durable-ack CAPABILITY-GAP terminal: set (before the
// terminalError latch, so a checkError() caller that observes the latch is
// guaranteed to observe this marker too) when a reconnect sweep threw
@@ -1038,6 +1050,19 @@ public static WebSocketClient connectWithRetry(
LOG.error("{} hit terminal upgrade error, won't retry: {}",
contextLabel, e.getMessage());
throw e;
+ } catch (QwpCredentialUnavailableException e) {
+ // A credential the client cannot ACQUIRE (the configured token provider threw) is NOT a
+ // transport outage: retrying the connect cannot conjure a token the provider will not hand
+ // over, so fail fast with the provider's own exception rather than burn the whole connect
+ // budget treating it as a reachable-server problem (which would block build() for up to
+ // maxDurationMillis, default 5 min, and surface a transport-shaped wrapper). Mirrors the
+ // foreground OFF-mode connect (QwpWebSocketSender) and the background reconnect loop above,
+ // which both give credential acquisition its own terminal handling; only this SYNC
+ // initial-connect path lacked it. QwpCredentialUnavailableException is a LineSenderException,
+ // disjoint from the HttpClientException-based terminal set above, so it reaches here.
+ LOG.error("{} could not acquire a credential, won't retry: {}",
+ contextLabel, e.getMessage());
+ throw e.providerFailure();
} catch (Throwable e) {
if (e instanceof Error) {
// JVM/programming failure (OOM, LinkageError): not a
@@ -1176,6 +1201,22 @@ public void checkError() {
}
}
+ /**
+ * The typed rotating-credential auth terminal (401/403), or {@code null} if the loop's terminal
+ * (if any) is a different failure class. Non-null only after {@link #checkError()} started
+ * throwing: the marker is written before the {@code terminalError} latch, both on the I/O thread.
+ *
+ * Consumer contract: the orphan drainer ({@code BackgroundDrainer}) checks this after a
+ * {@code checkError()} throw to route a mid-drain 401/403 against a ROTATING credential into its
+ * bounded rotating-401 ride-out (the header is re-derived per attempt, so the rejection can be a
+ * self-healing window a freshly pulled token clears) rather than quarantining the slot. A constant
+ * credential never sets it. Package-private on purpose -- the foreground sender must not branch
+ * on it.
+ */
+ QwpAuthFailedException authTerminal() {
+ return authTerminal;
+ }
+
/**
* The typed durable-ack capability-gap terminal, or {@code null} if the
* loop's terminal (if any) is a different failure class. Non-null only
@@ -1709,11 +1750,13 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
// INVARIANT B: a store-and-forward loop must NEVER terminate on a
// wall-clock reconnect budget. A replica-only / all-endpoints-replica
// window is TRANSIENT -- a replica gets promoted, a primary reappears --
- // so this background loop retries for as long as it is running, backing
- // off between attempts. Endpoint-policy failures (auth / non-421
- // upgrade / durable-ack capability gap) are terminal only for orphan
- // drainers. Foreground senders retry them from asynchronous startup onward
- // so a credential or cluster capability rotation cannot stop the producer. SF
+ // and so is a token-provider failure -- the IdP becomes reachable again,
+ // or the user completes an interactive sign-in -- so this background loop
+ // retries all of them for as long as it is running, backing off between
+ // attempts. Endpoint-policy failures (auth / non-421 upgrade /
+ // durable-ack capability gap) are terminal only for orphan drainers.
+ // Foreground senders retry them from asynchronous startup onward so a
+ // credential or cluster capability rotation cannot stop the producer. SF
// exhaustion is surfaced to the PRODUCER as append backpressure, never
// here. reconnect_max_duration_millis is intentionally NOT consulted by
// THIS loop. Its holders pass it explicitly where it does apply: the
@@ -1781,11 +1824,37 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
resetCatchUpCapGapEpisode();
} catch (QwpAuthFailedException | WebSocketUpgradeException e) {
if (endpointPolicyFailureIsTerminal()) {
- // Orphans return control to their quarantine owner.
- // WebSocketUpgradeException reaching here is always non-421:
- // role rejects are classified into the transient branch below.
- LOG.error("terminal upgrade error during {} -- won't retry: {}",
- phase, e.getMessage());
+ // A 401/403 against a ROTATING credential on an orphan drainer is NOT uniformly
+ // fatal: the Authorization header is re-derived from the caller's token provider on
+ // every attempt, so the rejection can be a self-healing window (a revocation landing
+ // mid-flight, the IdP rotating signing keys, clock skew past the token's own margin)
+ // that a freshly pulled token clears. Hand it back as a RECOVERABLE auth-terminal --
+ // exactly as a capability gap does -- so BackgroundDrainer recycles it through
+ // connectWithDurableAckRetry()'s bounded rotating-401 ride-out instead of
+ // quarantining on the first rejection and permanently abandoning replayable data.
+ // A CONSTANT credential (or a non-421 upgrade reject) is uniformly rejected across
+ // the cluster and stays fatal. This gates on reconnectPolicy == ORPHAN, so an
+ // INITIALIZING foreground 401 (endpointPolicyFailureIsTerminal via !hasEverConnected)
+ // is never masked and still reaches the caller.
+ final boolean rotatingCredentialReject = reconnectPolicy == ReconnectPolicy.ORPHAN
+ && e instanceof QwpAuthFailedException
+ && reconnectFactory.hasDynamicCredential();
+ if (rotatingCredentialReject) {
+ if (terminalError == null) {
+ // Publish the marker before terminalError, the volatile first-writer-wins
+ // latch the owner observes -- same ordering as capabilityGapTerminal.
+ authTerminal = (QwpAuthFailedException) e;
+ }
+ LOG.warn("rotating credential rejected during {} -- handing the slot back to the "
+ + "drainer to retry with a freshly pulled token: {}",
+ phase, e.getMessage());
+ } else {
+ // Orphans return control to their quarantine owner.
+ // WebSocketUpgradeException reaching here is always non-421:
+ // role rejects are classified into the transient branch below.
+ LOG.error("terminal upgrade error during {} -- won't retry: {}",
+ phase, e.getMessage());
+ }
long fromFsn = engine.ackedFsn() + 1L;
long toFsn = Math.max(fromFsn, engine.publishedFsn());
SenderError err = new SenderError(
@@ -1856,6 +1925,41 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
phase, attempts, e.getMessage());
lastLogNanos = now;
}
+ } catch (QwpCredentialUnavailableException e) {
+ // The token provider threw instead of returning a credential (a failed silent refresh, an
+ // interactive sign-in in progress on another thread, or not signed in yet). In the RUNNING
+ // background drainer this is a TRANSIENT outage like any other under Invariant B: the provider
+ // hands over a token again once the IdP is reachable or the user finishes signing in, and the
+ // un-acked rows stay safe in on-disk SF meanwhile. So retry indefinitely with capped backoff --
+ // NEVER bound by a wall-clock budget and NEVER latch a terminal, which would drop a producer
+ // that store-and-forward promised to keep alive on a recoverable fault. The foreground/SYNC
+ // initial connect still fails fast with the provider's own exception (connectWithRetry, and the
+ // OFF-mode connect in QwpWebSocketSender), because a connectivity error is only the caller's to
+ // see DURING initialization, not after the drainer is running.
+ //
+ // Ends any open cap-gap episode like every other unrelated reconnect state: we never reached a
+ // node to observe its batch cap, so this outage's wall clock must not accrue toward the orphan
+ // cap-gap dwell (see MAX_CATCHUP_CAP_GAP_ATTEMPTS).
+ resetCatchUpCapGapEpisode();
+ lastReconnectError = e;
+ // Retrying must not be programmatically INVISIBLE, exactly as for the auth/upgrade and
+ // durable-ack policy failures above: a revoked refresh token or a permanently unreachable IdP
+ // is not self-healing, yet flush() keeps returning success while SF absorbs the rows. Without
+ // this dispatch the only signal is a throttled slf4j WARN - and this library ships embedded,
+ // frequently with no binding configured - until SF fills and the failure resurfaces as ring
+ // backpressure, pointing the operator at disk sizing instead of at their credentials. It stays
+ // RETRIABLE, not TERMINAL: the handler learns the wire is down while the producer stays alive
+ // and no data is at risk (Invariant B).
+ dispatchRetriedEndpointPolicyFailure(
+ SenderError.Category.SECURITY_ERROR, "credential-unavailable: " + e.getMessage());
+ long now = System.nanoTime();
+ if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
+ LOG.warn("{} attempt {}: the token provider failed ({}); retrying with capped backoff -- "
+ + "the sender keeps buffering to SF and recovers once a token is available",
+ phase, attempts, e.getMessage());
+ lastLogNanos = now;
+ }
+ // fall through to the shared capped-backoff block
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
// Role mismatch: every reachable endpoint role-rejected the
// upgrade -- right now they are all replicas / primary-catchup.
@@ -3527,6 +3631,24 @@ public enum ReconnectPolicy {
public interface ReconnectFactory {
WebSocketClient reconnect() throws Exception;
+ /**
+ * Whether this factory re-derives its {@code Authorization} header from a caller-supplied token
+ * provider on every attempt, rather than presenting a constant captured once.
+ *
+ * The orphan drainer's terminal policy reads this. A {@code 401} against a CONSTANT credential is a
+ * permanent misconfiguration, so quarantining the slot on the first one is correct. Against a
+ * ROTATING credential the same {@code 401} can be a recoverable window - clock skew past the
+ * token's own skew margin, a revocation landing mid-flight, an identity provider rotating signing
+ * keys - and a later attempt carries a freshly pulled token, so quarantining immediately would
+ * abandon replayable data permanently on a fault that heals itself.
+ *
+ * Default: {@code false}, the conservative answer. A factory that cannot tell (a test double, a
+ * transport with no credential at all) keeps the pre-existing fail-fast behaviour.
+ */
+ default boolean hasDynamicCredential() {
+ return false;
+ }
+
/**
* Cancellable variant of {@link #reconnect()}. The loop passes a
* per-attempt {@link ConnectCancellation} so a transport that blocks
@@ -3581,11 +3703,33 @@ public static final class ConnectCancellation {
// Latched once close() requested cancellation. Written by the owner
// thread (cancel); read by the I/O thread's pre-connect guard.
private volatile boolean cancelled;
+ // The I/O thread while it is inside a credential pull -- the one
+ // blocking call in the connect walk that closeTraffic() cannot reach,
+ // because it runs caller-supplied HttpTokenProvider code. Written by
+ // the I/O thread only (publishCredentialPull/clearCredentialPull);
+ // read by the owner thread (cancel). null when no pull is in flight.
+ private volatile Thread credentialPullThread;
+
+ public void clearCredentialPull() {
+ credentialPullThread = null;
+ }
public boolean isCancelled() {
return cancelled;
}
+ /**
+ * I/O-thread hook: record this thread as being about to enter a
+ * credential pull, BEFORE the blocking call. Pairs with
+ * {@link #cancel()} the same way {@link #publish(WebSocketClient)}
+ * does, except the break lever is an interrupt rather than
+ * {@code closeTraffic()} -- a token provider is caller code and owns
+ * no socket the sender can shut down.
+ */
+ public void publishCredentialPull(Thread thread) {
+ credentialPullThread = thread;
+ }
+
/**
* I/O-thread hook: record the client the walk is about to block on,
* BEFORE the blocking {@code connect()}. Pairs with {@link #cancel()}
@@ -3623,6 +3767,20 @@ void cancel() {
if (c != null) {
c.closeTraffic();
}
+ // A credential pull is caller code, so closeTraffic() cannot reach it, yet it can block far
+ // longer than close()'s shutdown budget: OidcDeviceAuth.getToken() waits up to
+ // 4 x httpTimeoutMillis (120s by default) behind a peer's silent refresh, against a 30s
+ // DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS. During an IdP outage the drainer sits inside a pull for
+ // most of every retry cycle, so close() lands there routinely, not just in a narrow race. An
+ // interrupt is the only lever that reaches a Java-level wait; OidcDeviceAuth converts it into a
+ // provider failure, which the reconnect loop treats as a transient outage and then observes the
+ // abort. Fires ONLY while a pull is in flight, so a sender with no token provider is untouched.
+ // It does NOT cover a provider stalled in an OS-level TCP connect, which ignores interrupts --
+ // close() still loud-fails on its budget there, as it did before.
+ Thread t = credentialPullThread;
+ if (t != null) {
+ t.interrupt();
+ }
}
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
index 34168b44d..183876a71 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
@@ -978,14 +978,6 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
}
}
- /**
- * Validates every chunk and copies the entries of each proven-good one into
- * {@code dstAddr}, in a single pass. {@code dstAddr} must have room for {@code len}
- * bytes -- the entry region is a subset of the file, so that always suffices.
- * Stops at the first chunk that is torn, fails its CRC, or is internally
- * inconsistent, exactly as the two-pass version did, so the trusted prefix is
- * unchanged.
- */
/**
* Stats {@code filePath} and, when the stat fails, captures {@code errno} with NO
* intervening call.
@@ -1019,6 +1011,14 @@ private static long statLength(FilesFacade ff, String filePath, int[] errnoOut)
}
}
+ /**
+ * Validates every chunk and copies the entries of each proven-good one into
+ * {@code dstAddr}, in a single pass. {@code dstAddr} must have room for {@code len}
+ * bytes -- the entry region is a subset of the file, so that always suffices.
+ * Stops at the first chunk that is torn, fails its CRC, or is internally
+ * inconsistent, exactly as the two-pass version did, so the trusted prefix is
+ * unchanged.
+ */
private static RecoveryScan scanAndCopyRecoveredChunks(long inputAddr, int len, long dstAddr) {
Varint v = new Varint();
int count = 0;
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java
index 1bfd2617e..9f01f0ab8 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java
@@ -222,10 +222,13 @@ public ColumnBuffer getOrCreateColumn(CharSequence name, byte type, boolean useN
inProgressColumnCount++;
return col;
}
- throw new LineSenderException(
- name.length() > MAX_COLUMN_NAME_LENGTH ? "column name too long [maxLength=" + MAX_COLUMN_NAME_LENGTH + "]"
- : "column name contains illegal characters: " + name
- );
+ if (name.length() > MAX_COLUMN_NAME_LENGTH) {
+ throw new LineSenderException("column name too long [maxLength=" + MAX_COLUMN_NAME_LENGTH + "]");
+ }
+ // sanitize the rejected name before it reaches the message (and any log/terminal): a name that failed
+ // validation can carry BOM/bidi/zero-width/control chars that would otherwise reorder, hide or forge what
+ // a human reads, matching how the ILP name/error render escapes untrusted text
+ throw new LineSenderException("column name contains illegal characters: ").putAsPrintable(name);
}
public ColumnBuffer getOrCreateDesignatedTimestampColumn(byte type) {
diff --git a/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java
index d5ff3db45..836611159 100644
--- a/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java
+++ b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java
@@ -36,10 +36,13 @@ final class PoolHousekeeper {
// in flight when close() arrives finishes well within this join (C1 fix).
// The recovery build that precedes the drain is bounded separately --
// recoverers force initial_connect_mode=OFF, so the build makes at most one
- // connect attempt rather than a SYNC reconnect-budget retry (M1). The lone
- // case that can still overrun this join is an in-flight connect to a
- // black-holed host (no application-level connect timeout in the transport);
- // see the residual-window note on SenderPool.recoverOneSlotStep.
+ // connect attempt rather than a SYNC reconnect-budget retry (M1). A recovery
+ // build also pulls a credential when a token provider is configured, and
+ // that wait dwarfs this join; stop() escalates to an interrupt for it. The
+ // lone case that survives even that is an in-flight connect to a black-holed
+ // host, which blocks in a syscall no interrupt breaks (the transport exposes
+ // no application-level connect timeout); see the residual-window note on
+ // SenderPool.recoverOneSlotStep.
static final long STOP_TIMEOUT_MILLIS = 2_000;
private final long intervalMillis;
@@ -68,6 +71,36 @@ void stop() {
}
try {
thread.join(STOP_TIMEOUT_MILLIS);
+ if (thread.isAlive()) {
+ // The stop flag only reaches the loop BETWEEN steps. A step blocked inside a recovery
+ // build is unreachable by it, and since recovery builds acquired a token provider the
+ // longest such block is a credential pull: OidcDeviceAuth.getToken() documents a wait of up
+ // to four times httpTimeoutMillis behind a peer's refresh, plus a token-store lock wait,
+ // which together dwarf this join. Returning anyway leaves the recoverer holding its
+ // store-and-forward slot flock after close() has returned, so an immediate reopen fails
+ // with "sf slot already in use" and the detached build's engine, mmaps and I/O thread leak
+ // -- the very window this pool's per-slot ids and the drain_orphans(false) forced on
+ // recovery builds exist to eliminate.
+ //
+ // Interrupt and re-join. The waits this is aimed at are interruptible: acquireForGetToken
+ // polls a timed tryLock, and FileTokenStore's two lock waits abandon and re-assert the flag.
+ // The pull then throws, the step's caller swallows it (recovery is best-effort), and the
+ // loop reaches its stop check and releases the flock on its own.
+ //
+ // Not ALL of the pull is interruptible, and the join above is the only bound on the rest:
+ // the token POST's connect, send, await and parse phases run on the native HTTP client
+ // (raw fd + epoll/kqueue), which no interrupt breaks -- each is bounded by
+ // httpTimeoutMillis, and DNS resolution is not bounded at all. So a pull already inside its
+ // round trip outlives both joins, exactly as an in-flight connect to a black-holed host
+ // does. This escalation shortens the common case; it does not make the window impossible.
+ //
+ // The flag must not outlive the interrupt's target. Sender.close() and QwpQueryClient
+ // close() are interrupt-neutral precisely because this thread goes on to close delegates:
+ // a CARRIED flag makes CountDownLatch.await return instantly and would report a flock still
+ // held that was released fine.
+ thread.interrupt();
+ thread.join(STOP_TIMEOUT_MILLIS);
+ }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
@@ -84,9 +117,10 @@ private void runLoop() {
// forced OFF (at most one connect attempt, never a SYNC
// reconnect-budget retry -- M1), and we re-check stop every step, so
// a close() landing mid-recovery normally only waits out a single
- // bounded drain and the join in stop() does not time out. The sole
- // residual overrun is an in-flight connect to a black-holed host;
- // see SenderPool.recoverOneSlotStep.
+ // bounded drain and the join in stop() does not time out. A step
+ // blocked in a credential pull is broken by stop()'s interrupt; the
+ // sole residual overrun is an in-flight connect to a black-holed
+ // host, which no interrupt breaks. See SenderPool.recoverOneSlotStep.
// While recovery still has work we skip the idle wait so the backlog
// drains promptly; once done we fall back to the normal interval.
// No-op once recovery completes or the pool is closing. Best-effort:
diff --git a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java
index ca3aec15e..8e858c019 100644
--- a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java
+++ b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java
@@ -24,6 +24,7 @@
package io.questdb.client.impl;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.QueryException;
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
import org.jetbrains.annotations.TestOnly;
@@ -91,6 +92,7 @@ public final class QueryClientPool implements AutoCloseable {
private final int maxSize;
private final int minSize;
private final AtomicInteger nextSlotIndex = new AtomicInteger();
+ private final HttpTokenProvider tokenProvider;
private final Condition workerReleased;
private volatile boolean closed;
// Upper bound on the Query.close() drain wait; see
@@ -113,7 +115,7 @@ public QueryClientPool(
long maxLifetimeMillis
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
- idleTimeoutMillis, maxLifetimeMillis, null);
+ idleTimeoutMillis, maxLifetimeMillis, null, null, null);
}
// Constructor exposing the connectHook seam. Production (QuestDBImpl) passes
@@ -131,7 +133,7 @@ public QueryClientPool(
Consumer connectHook
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
- idleTimeoutMillis, maxLifetimeMillis, connectHook, null);
+ idleTimeoutMillis, maxLifetimeMillis, connectHook, null, null);
}
// Constructor exposing both the connectHook and startHook seams. Production
@@ -148,12 +150,28 @@ public QueryClientPool(
long maxLifetimeMillis,
Consumer connectHook,
Consumer startHook
+ ) {
+ this(configurationString, minSize, maxSize, acquireTimeoutMillis,
+ idleTimeoutMillis, maxLifetimeMillis, connectHook, startHook, null);
+ }
+
+ QueryClientPool(
+ String configurationString,
+ int minSize,
+ int maxSize,
+ long acquireTimeoutMillis,
+ long idleTimeoutMillis,
+ long maxLifetimeMillis,
+ Consumer connectHook,
+ Consumer startHook,
+ HttpTokenProvider tokenProvider
) {
if (minSize < 0 || maxSize < 1 || minSize > maxSize) {
throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize);
}
this.connectHook = connectHook != null ? connectHook : QwpQueryClient::connect;
this.startHook = startHook != null ? startHook : QueryWorker::start;
+ this.tokenProvider = tokenProvider;
this.configurationString = configurationString;
this.minSize = minSize;
this.maxSize = maxSize;
@@ -578,6 +596,9 @@ public void setCreationWaitRetryHookForTesting(Runnable hook) {
private QueryWorker createUnlocked() {
QwpQueryClient client = QwpQueryClient.fromConfig(configurationString);
try {
+ if (tokenProvider != null) {
+ client.withBearerTokenProvider(tokenProvider);
+ }
connectHook.accept(client);
} catch (Throwable e) {
// Catch Throwable, not just RuntimeException: connect() runs a heavy
diff --git a/core/src/main/java/io/questdb/client/impl/QueryWorker.java b/core/src/main/java/io/questdb/client/impl/QueryWorker.java
index 040ade630..568ac0dfe 100644
--- a/core/src/main/java/io/questdb/client/impl/QueryWorker.java
+++ b/core/src/main/java/io/questdb/client/impl/QueryWorker.java
@@ -206,6 +206,10 @@ void releaseToPool(long gen) {
}
void shutdown() {
+ // Take the caller's cancellation out of the way for the whole teardown and hand it back at the
+ // end. Every join below would otherwise throw on arrival rather than on a real timeout; see the
+ // join site and QwpQueryClient.close() for what that costs.
+ boolean callerWasInterrupted = Thread.interrupted();
shuttingDown = true;
signalLock.lock();
try {
@@ -236,9 +240,15 @@ void shutdown() {
// the worker thread and the client's native socket/buffers.
}
try {
+ // Interrupt-neutral for the same reason QwpQueryClient.close() is: reapIdle() reaches
+ // here on the housekeeper thread, which PoolHousekeeper.stop() may have interrupted to
+ // break a credential pull. A carried flag makes this join throw instantly without ever
+ // checking whether the dispatch thread exited, so client.close() below would run
+ // alongside a still-live dispatch thread. Restored by the caller-flag handling in
+ // shutdown()'s outer finally.
thread.join(SHUTDOWN_JOIN_MILLIS);
} catch (InterruptedException e) {
- Thread.currentThread().interrupt();
+ callerWasInterrupted = true;
}
} finally {
// close() must run even if cancel()/join() threw, otherwise the
@@ -249,6 +259,9 @@ void shutdown() {
client.close();
} catch (Throwable ignored) {
}
+ if (callerWasInterrupted) {
+ Thread.currentThread().interrupt();
+ }
}
}
diff --git a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
index 75ee0a267..574d6b59e 100644
--- a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
+++ b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
@@ -24,6 +24,7 @@
package io.questdb.client.impl;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.QuestDB;
import io.questdb.client.Query;
import io.questdb.client.Sender;
@@ -70,10 +71,33 @@ public QuestDBImpl(
) {
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
- housekeeperIntervalMillis, queryCloseTimeoutMillis, null, null,
+ housekeeperIntervalMillis, queryCloseTimeoutMillis, null,
errorHandler, connectionListener, drainerListener);
}
+ public QuestDBImpl(
+ String ingestConfig,
+ String queryConfig,
+ int senderMin,
+ int senderMax,
+ int queryMin,
+ int queryMax,
+ long acquireTimeoutMillis,
+ long idleTimeoutMillis,
+ long maxLifetimeMillis,
+ long housekeeperIntervalMillis,
+ long queryCloseTimeoutMillis,
+ HttpTokenProvider tokenProvider,
+ SenderErrorHandler errorHandler,
+ SenderConnectionListener connectionListener,
+ BackgroundDrainerListener drainerListener
+ ) {
+ this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
+ acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
+ housekeeperIntervalMillis, queryCloseTimeoutMillis, null, null,
+ tokenProvider, errorHandler, connectionListener, drainerListener);
+ }
+
// Test-only constructor exposing the senderFactory and connectHook seams:
// production uses the public overload above, which passes null for both ->
// the real native build/connect paths. White-box error-safety tests in
@@ -98,7 +122,7 @@ public QuestDBImpl(
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
housekeeperIntervalMillis, QueryClientPool.DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS,
- senderFactory, connectHook, null, null, null);
+ senderFactory, connectHook, null, null, null, null);
}
// Full constructor adding the ingest-side errorHandler/connectionListener/
@@ -120,6 +144,7 @@ public QuestDBImpl(
long queryCloseTimeoutMillis,
IntFunction senderFactory,
Consumer connectHook,
+ HttpTokenProvider tokenProvider,
SenderErrorHandler errorHandler,
SenderConnectionListener connectionListener,
BackgroundDrainerListener drainerListener
@@ -135,10 +160,10 @@ public QuestDBImpl(
// build() never blocks on a slow / reachable-but-not-acking
// server; the housekeeper drives it via runStartupRecoveryStep().
true,
- errorHandler, connectionListener, drainerListener);
+ errorHandler, connectionListener, drainerListener, tokenProvider);
builtQueryPool = new QueryClientPool(
queryConfig, queryMin, queryMax, acquireTimeoutMillis,
- idleTimeoutMillis, maxLifetimeMillis, connectHook);
+ idleTimeoutMillis, maxLifetimeMillis, connectHook, null, tokenProvider);
builtQueryPool.closeQueryTimeoutMillis(queryCloseTimeoutMillis);
builtHousekeeper = new PoolHousekeeper(builtSenderPool, builtQueryPool, housekeeperIntervalMillis);
builtHousekeeper.start();
diff --git a/core/src/main/java/io/questdb/client/impl/SenderPool.java b/core/src/main/java/io/questdb/client/impl/SenderPool.java
index a8a10d621..aebb95c97 100644
--- a/core/src/main/java/io/questdb/client/impl/SenderPool.java
+++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java
@@ -24,6 +24,7 @@
package io.questdb.client.impl;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.Sender;
import io.questdb.client.SenderConnectionListener;
import io.questdb.client.SenderError;
@@ -154,6 +155,7 @@ public final class SenderPool implements AutoCloseable {
private final BackgroundDrainerListener drainerListener;
private final SenderErrorHandler errorHandler;
private final long idleTimeoutMillis;
+ private final HttpTokenProvider tokenProvider;
// Delivery channel for recovery-delegate errors that pass the
// isRecoveryEventUserRelevant filter. Pool-owned so a slow user handler
// can never stall the recovery driver / housekeeper thread or overrun
@@ -354,7 +356,7 @@ public SenderPool(
long maxLifetimeMillis
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
- idleTimeoutMillis, maxLifetimeMillis, null, false, null, null, null, null, null, null, null);
+ idleTimeoutMillis, maxLifetimeMillis, null, false, null, null, null, null, null, null, null, null);
}
// Test-only constructor exposing the senderFactory seam: production builds
@@ -397,7 +399,7 @@ public SenderPool(
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
idleTimeoutMillis, maxLifetimeMillis, senderFactory,
- deferStartupRecovery, null, null, null, null, null, null, null);
+ deferStartupRecovery, null, null, null, null, null, null, null, null);
}
// Test-only constructor adding a deterministic fault hook for the ownership
@@ -416,7 +418,7 @@ public SenderPool(
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
idleTimeoutMillis, maxLifetimeMillis, senderFactory,
- deferStartupRecovery, null, null, null, postFactoryHook, null, null, null);
+ deferStartupRecovery, null, null, null, postFactoryHook, null, null, null, null);
}
@TestOnly
@@ -433,6 +435,7 @@ public static SenderPool createWithRecoveryControlsForTesting(
return new SenderPool(configurationString, minSize, maxSize, acquireTimeoutMillis,
Long.MAX_VALUE, Long.MAX_VALUE, senderFactory, false,
null, null, null, null, recoveryThreadFactory, recoveryWaiter,
+ null,
beforeFailedRecoveryJoinHook);
}
@@ -457,7 +460,27 @@ public static SenderPool createWithRecoveryControlsForTesting(
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
idleTimeoutMillis, maxLifetimeMillis, senderFactory,
deferStartupRecovery, errorHandler, connectionListener,
- drainerListener, null, null, null, null);
+ drainerListener, null);
+ }
+
+ SenderPool(
+ String configurationString,
+ int minSize,
+ int maxSize,
+ long acquireTimeoutMillis,
+ long idleTimeoutMillis,
+ long maxLifetimeMillis,
+ IntFunction senderFactory,
+ boolean deferStartupRecovery,
+ SenderErrorHandler errorHandler,
+ SenderConnectionListener connectionListener,
+ BackgroundDrainerListener drainerListener,
+ HttpTokenProvider tokenProvider
+ ) {
+ this(configurationString, minSize, maxSize, acquireTimeoutMillis,
+ idleTimeoutMillis, maxLifetimeMillis, senderFactory,
+ deferStartupRecovery, errorHandler, connectionListener,
+ drainerListener, null, null, null, tokenProvider, null);
}
private SenderPool(
@@ -475,6 +498,7 @@ private SenderPool(
Runnable postFactoryHook,
ThreadFactory recoveryThreadFactory,
Runnable recoveryWaiter,
+ HttpTokenProvider tokenProvider,
Runnable beforeFailedRecoveryJoinHook
) {
if (minSize < 0 || maxSize < 1 || minSize > maxSize) {
@@ -483,6 +507,7 @@ private SenderPool(
this.errorHandler = errorHandler;
this.connectionListener = connectionListener;
this.drainerListener = drainerListener;
+ this.tokenProvider = tokenProvider;
this.senderFactory = senderFactory != null ? senderFactory : this::defaultSender;
// An injected factory (tests) drives recovery too, preserving the
// white-box recovery seam; production recovery forces OFF-mode connects
@@ -511,6 +536,11 @@ private SenderPool(
// us whether SF is on and, if so, the base slot id to derive
// per-sender ids from.
Sender.LineSenderBuilder probe = Sender.builder(configurationString);
+ if (tokenProvider != null) {
+ // Validate fixed-config credentials vs. the provider even when the
+ // pool is fully lazy and no sender is built yet.
+ probe.httpTokenProvider(tokenProvider);
+ }
this.storeAndForward = probe.isStoreAndForwardEnabled();
this.slotBaseId = this.storeAndForward ? probe.getConfiguredSenderId() : null;
this.sfDir = this.storeAndForward ? probe.getConfiguredSfDir() : null;
@@ -730,7 +760,10 @@ boolean runStartupRecoveryStep() {
* minutes-long block a {@code reconnect_*}-tuned config used to cause (M1).
* One residual window remains and is NOT closed here: a single in-flight
* connect to a black-holed/firewalled host blocks on the OS connect timeout
- * (the transport exposes no application-level connect timeout to clamp it).
+ * (the transport exposes no application-level connect timeout to clamp it),
+ * and unlike the credential pull that a token provider adds ahead of it - a
+ * wait of up to four times httpTimeoutMillis plus a token-store lock wait -
+ * it blocks in a syscall that the stop path's interrupt cannot break.
* If {@code close()} lands during that one connect, its driver join can
* still time out and the detached build releases the slot flock shortly
* after {@code close()} returns. No data is lost (the slot stays durable on
@@ -1677,6 +1710,15 @@ private void stopStartupRecoveryDriver() {
}
try {
startupRecoveryThread.join(PoolHousekeeper.STOP_TIMEOUT_MILLIS);
+ if (startupRecoveryThread.isAlive()) {
+ // Same escalation, and for the same reason, as PoolHousekeeper.stop(): the closed flag
+ // reaches the driver only between steps, so a step blocked inside a recovery build's
+ // credential pull outlives this join and returns while still holding the slot flock.
+ // The pull's waits are interruptible, so an interrupt unwinds it and lets the driver
+ // release the flock before close() returns.
+ startupRecoveryThread.interrupt();
+ startupRecoveryThread.join(PoolHousekeeper.STOP_TIMEOUT_MILLIS);
+ }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
@@ -1993,6 +2035,13 @@ public void onError(SenderError error) {
return builder;
}
+ private Sender.LineSenderBuilder applyTokenProvider(Sender.LineSenderBuilder builder) {
+ if (tokenProvider != null) {
+ builder.httpTokenProvider(tokenProvider);
+ }
+ return builder;
+ }
+
// Applies the user-supplied ingest callbacks to a sender builder. Null
// callbacks are skipped so the sender keeps its loud-not-silent default.
private Sender.LineSenderBuilder applyUserCallbacks(Sender.LineSenderBuilder builder) {
@@ -2021,7 +2070,7 @@ private static boolean isRecoveryEventUserRelevant(SenderError e) {
private Sender buildManagedSlotSender(int slotIndex, boolean forRecovery) {
if (!storeAndForward) {
- return applyUserCallbacks(Sender.builder(configurationString)).build();
+ return applyUserCallbacks(applyTokenProvider(Sender.builder(configurationString))).build();
}
// Give this pooled sender its own slot dir /-
// so concurrent SF senders sharing one sf_dir never collide on
@@ -2089,6 +2138,7 @@ private Sender buildManagedSlotSender(int slotIndex, boolean forRecovery) {
// SenderErrorDispatcher so a slow handler cannot stall the recovery
// driver or housekeeper thread. connectionListener and drainerListener
// remain unset on recovery builds.
+ builder = applyTokenProvider(builder);
return (forRecovery ? applyRecoveryCallbacks(builder) : applyUserCallbacks(builder)).build();
}
diff --git a/core/src/main/java/io/questdb/client/std/Numbers.java b/core/src/main/java/io/questdb/client/std/Numbers.java
index c7b8acc97..78a3bf4c7 100644
--- a/core/src/main/java/io/questdb/client/std/Numbers.java
+++ b/core/src/main/java/io/questdb/client/std/Numbers.java
@@ -343,6 +343,28 @@ public static long parseHexLong(CharSequence sequence) throws NumericException {
return parseHexLong(sequence, 0, sequence.length());
}
+ /**
+ * Parses a hexadecimal sequence into a long, reading a full-width 16-digit word as its
+ * TWO'S-COMPLEMENT value: {@code ffffffffffffffff} is {@code -1}, not an error. This matches
+ * {@link #parseHexInt(CharSequence, int, int)} beside it and the server-side {@code io.questdb.std.Numbers}
+ * of the same name, whose {@code Long256} decoding depends on the wrap.
+ *
+ * Anything longer than 16 significant digits silently discards its high bits, and a caller
+ * parsing a COUNT it did not choose must bound the digits itself rather than lean on this method to
+ * do it. Each overflow residue breaks a length-prefixed format its own way, and
+ * {@code AbstractChunkedResponse} is the worked example: an HTTP chunk size of
+ * {@code 8000000000000000} wraps negative and hangs a framing state machine, one of
+ * {@code 10000000000000000} wraps to zero and reads as the terminal chunk (a truncated body reported
+ * as complete), and longer values wrap to short positive counts that mis-frame everything after them.
+ * It guards by counting significant digits BEFORE calling here, which is the only form that works -
+ * the zero residue is indistinguishable from a genuine {@code 0} once parsed.
+ *
+ * @param sequence the characters to parse
+ * @param lo inclusive start
+ * @param hi exclusive end
+ * @return the parsed value, wrapping on overflow
+ * @throws NumericException if the sequence is empty or holds a non-hex character
+ */
public static long parseHexLong(CharSequence sequence, int lo, int hi) throws NumericException {
if (hi == 0) {
throw NumericException.instance().put("empty hex string");
diff --git a/core/src/main/java/io/questdb/client/std/str/DirectUtf8Sink.java b/core/src/main/java/io/questdb/client/std/str/DirectUtf8Sink.java
index 5b1921a67..8aae811dc 100644
--- a/core/src/main/java/io/questdb/client/std/str/DirectUtf8Sink.java
+++ b/core/src/main/java/io/questdb/client/std/str/DirectUtf8Sink.java
@@ -25,6 +25,7 @@
package io.questdb.client.std.str;
import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
import io.questdb.client.std.bytes.DirectByteSink;
import io.questdb.client.std.bytes.NativeByteSink;
import org.jetbrains.annotations.NotNull;
@@ -103,6 +104,28 @@ public DirectUtf8Sink put(byte b) {
return this;
}
+ /**
+ * Appends the bytes of {@code src} in {@code [lo, hi)} verbatim in a single bulk copy, rather than byte by
+ * byte. The ascii hint is set to {@code false} conservatively (the bytes are treated as opaque), so callers
+ * that rely on {@link #isAscii()} should not use this overload for ascii-only content.
+ */
+ public DirectUtf8Sink put(byte[] src, int lo, int hi) {
+ // a real check, not an assert: this is public API doing an unchecked Unsafe.copyMemory, and client apps
+ // typically run without -ea, so a bad range must fail with a clear exception rather than a native
+ // out-of-bounds read that corrupts memory or crashes the JVM
+ if (lo < 0 || hi > src.length || lo > hi) {
+ throw new IndexOutOfBoundsException("put(byte[]) range out of bounds [lo=" + lo + ", hi=" + hi + ", len=" + src.length + ']');
+ }
+ final int len = hi - lo;
+ if (len > 0) {
+ setAscii(false);
+ final long dest = sink.ensureCapacity(len);
+ Unsafe.getUnsafe().copyMemory(src, Unsafe.BYTE_OFFSET + lo, null, dest, len);
+ sink.advance(len);
+ }
+ return this;
+ }
+
@Override
public DirectUtf8Sink putAny(byte b) {
setAscii(isAscii() & b >= 0);
diff --git a/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java
new file mode 100644
index 000000000..c22c6b11d
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java
@@ -0,0 +1,100 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.std.str;
+
+import static io.questdb.client.std.Numbers.hexDigits;
+
+/**
+ * Shared classifier for whether a code point is safe to show in a terminal or a log line. It is the one
+ * source of truth for the client's display-escaping: {@link Utf16Sink#putAsPrintable(CharSequence)} escapes
+ * everything it rejects, and the OIDC auth layer strips it from untrusted identity-provider text. Left raw,
+ * attacker-influenced text - an ILP server's error body, a column name, a verification URL - could reorder,
+ * hide, or forge what a human reads (a right-to-left override, a zero-width joiner, an ANSI escape).
+ */
+public final class DisplaySafe {
+
+ private DisplaySafe() {
+ }
+
+ /**
+ * Returns {@code true} when {@code cp} can be shown verbatim, {@code false} when it must be escaped or
+ * stripped. A code point is unsafe if it is a control char (C0/C1, DEL), a Unicode format char (bidi
+ * embeddings/overrides/isolates, LRM/RLM marks, zero-width joiners, the BOM, supplementary-plane tag
+ * chars), a Unicode line/paragraph separator (U+2028/U+2029, which break a rendered log line), or a
+ * surrogate (a lone half, with no displayable meaning).
+ */
+ public static boolean isDisplaySafe(int cp) {
+ // Printable ASCII is the overwhelmingly common case and is never a control, format or surrogate char,
+ // so a single range check returns it without the Character.getType table lookup.
+ if (cp >= 0x20 && cp < 0x7f) {
+ return true;
+ }
+ if (Character.isISOControl(cp)) {
+ return false;
+ }
+ final int type = Character.getType(cp);
+ // FORMAT covers bidi/zero-width/joiners/BOM/tag chars; SURROGATE a lone half. LINE_SEPARATOR (U+2028)
+ // and PARAGRAPH_SEPARATOR (U+2029) are Unicode line breaks that split a rendered log line in
+ // ECMAScript/GUI/JSON log consumers, yet they are neither C0/C1 (isISOControl) nor FORMAT, so catch
+ // them here rather than let a tampered field forge an apparent extra log line.
+ if (type == Character.FORMAT || type == Character.SURROGATE
+ || type == Character.LINE_SEPARATOR || type == Character.PARAGRAPH_SEPARATOR) {
+ return false;
+ }
+ // The explicit bidi/BOM set is redundant with the FORMAT category on a conformant JDK, but kept as
+ // belt-and-suspenders on one that categorizes these differently. Hex literals (not char escapes) keep
+ // this source ASCII, so it carries none of the chars it guards.
+ return !(cp >= 0x202A && cp <= 0x202E) // LRE, RLE, PDF, LRO, RLO
+ && !(cp >= 0x2066 && cp <= 0x2069) // LRI, RLI, FSI, PDI
+ && cp != 0x200E && cp != 0x200F // LRM, RLM
+ && cp != 0xFEFF; // BOM / zero-width no-break space
+ }
+
+ /**
+ * The inverse of {@link #isDisplaySafe(int)}: {@code true} when {@code cp} must not reach a terminal or
+ * log line raw.
+ */
+ public static boolean isUnsafeForDisplay(int cp) {
+ return !isDisplaySafe(cp);
+ }
+
+ // Escapes a code point to one (BMP) or two (supplementary, as its surrogate pair) visible \\uXXXX
+ // sequences, so the escaped value still names the original char. Emitting all four hex digits keeps a
+ // char above U+00FF (e.g. U+202E) correct rather than truncated to its low byte. A static helper here
+ // (not a private method on Utf16Sink) keeps the source Java 8 - private interface methods are Java 9.
+ static void putUnicodeEscape(Utf16Sink sink, int cp) {
+ if (cp > 0xFFFF) {
+ putUnicodeEscape(sink, Character.highSurrogate(cp));
+ putUnicodeEscape(sink, Character.lowSurrogate(cp));
+ return;
+ }
+ sink.put('\\');
+ sink.put('u');
+ sink.put(hexDigits[(cp >> 12) & 0xF]);
+ sink.put(hexDigits[(cp >> 8) & 0xF]);
+ sink.put(hexDigits[(cp >> 4) & 0xF]);
+ sink.put(hexDigits[cp & 0xF]);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/std/str/StringSink.java b/core/src/main/java/io/questdb/client/std/str/StringSink.java
index 3c644aab5..aae8a83f9 100644
--- a/core/src/main/java/io/questdb/client/std/str/StringSink.java
+++ b/core/src/main/java/io/questdb/client/std/str/StringSink.java
@@ -28,6 +28,8 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import java.util.Arrays;
+
public class StringSink implements MutableUtf16Sink, CharSequence, Utf16Sink {
private char[] buffer;
@@ -125,6 +127,25 @@ public String toString() {
return new String(buffer, 0, pos);
}
+ /**
+ * Empties the sink AND overwrites its whole backing buffer, so nothing it has held remains readable
+ * through it. Best-effort hygiene for a sink that carried a secret - a bearer token, a refresh token, a
+ * device code - where {@link #clear()} is not enough: clear only rewinds the write position, leaving
+ * every character past that position in the array, so a long secret followed by a short write stays
+ * legible in the tail. It cannot reach a copy already handed out (a {@link #toString()} result, anything
+ * downstream wrote elsewhere), only this sink's own storage.
+ *
+ * Storage the sink has OUTGROWN is covered, but not by this method: {@link #checkCapacity(int)} zeroes
+ * each array as it hands off to a larger one, because by the time wipe() runs those generations are
+ * unreachable from here. Without that a sink small enough to grow while holding a secret - which is
+ * every default-sized one that carries a token - would leave a full copy per growth on the heap, and
+ * wiping the survivor would say nothing about them.
+ */
+ public void wipe() {
+ Arrays.fill(buffer, (char) 0);
+ pos = 0;
+ }
+
private void checkCapacity(int extra) {
int len = pos + extra;
if (buffer.length >= len) {
@@ -133,6 +154,15 @@ private void checkCapacity(int extra) {
len = Math.max(pos * 2, len);
final char[] n = new char[len];
System.arraycopy(buffer, 0, n, 0, pos);
+ // Zero the array being abandoned. wipe() can only reach the CURRENT buffer, so without this every
+ // generation growth leaves behind keeps its contents legible on the heap until the collector happens
+ // to overwrite that memory - which it is under no obligation to do, and a heap dump taken meanwhile
+ // shows the lot. That is not hypothetical for the sinks wipe() exists for: OidcDeviceAuth's formSink
+ // starts at 16 chars and builds "...&refresh_token=&client_id=...&scope=...", so it grows
+ // several times while already holding the whole refresh token, and each array it hands off carries a
+ // copy of it. The WHOLE array is zeroed, not just the live prefix: a sink cleared after holding a
+ // long secret keeps that secret past pos, which is the same retention wipe() itself closes.
+ Arrays.fill(buffer, (char) 0);
buffer = n;
}
}
diff --git a/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java b/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java
index f53e1ae5f..b18c29853 100644
--- a/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java
+++ b/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java
@@ -26,8 +26,6 @@
import org.jetbrains.annotations.Nullable;
-import static io.questdb.client.std.Numbers.hexDigits;
-
/**
* Family of sinks that write out character value as UTF16 encoded bytes. This interface
* is separate from {@link CharSink} to achieve two goals:
@@ -45,24 +43,61 @@ default Utf16Sink put(@Nullable Utf8Sequence us) {
}
default void putAsPrintable(CharSequence nonPrintable) {
- for (int i = 0, n = nonPrintable.length(); i < n; i++) {
- char c = nonPrintable.charAt(i);
- putAsPrintable(c);
+ // Scan by code point, not UTF-16 unit. A supplementary-plane format char (e.g. a U+E00xx language
+ // tag char) arrives as a surrogate pair whose halves report SURROGATE rather than FORMAT, and a
+ // lone surrogate likewise - per-unit scanning would pass both through raw. Judging the whole code
+ // point (via DisplaySafe, the shared classifier) escapes them, while a normal supplementary char
+ // such as an emoji is neither control nor format and is emitted verbatim.
+ //
+ // Classify before copying, and when nothing needs escaping hand the whole sequence to
+ // put(CharSequence) instead of walking it a character at a time. The escaping loop below appends
+ // with put(char), so an implementation like StringSink pays a capacity check per CHARACTER, where
+ // its put(CharSequence) override pays one for the whole sequence and then copies in a tight loop.
+ // That matters because the biggest input here is a server-supplied error body on a failed ILP
+ // flush, which the client does not cap - and which is almost always entirely printable, so the
+ // scan finds nothing and the copy is the bulk one. Mixed input costs this extra scan and then
+ // takes the loop as before; that is the rare case, and it is the one where correctness, not
+ // speed, is the point. Mirrors OidcDeviceAuth.sanitizeForDisplay, which returns its input
+ // untouched on the same test.
+ final int n = nonPrintable.length();
+ int firstUnsafe = -1;
+ for (int i = 0; i < n; ) {
+ final int cp = Character.codePointAt(nonPrintable, i);
+ if (!DisplaySafe.isDisplaySafe(cp)) {
+ firstUnsafe = i;
+ break;
+ }
+ i += Character.charCount(cp);
+ }
+ if (firstUnsafe < 0) {
+ put(nonPrintable);
+ return;
+ }
+ for (int i = 0; i < n; ) {
+ final int cp = Character.codePointAt(nonPrintable, i);
+ final int count = Character.charCount(cp);
+ if (DisplaySafe.isDisplaySafe(cp)) {
+ if (count == 1) {
+ put((char) cp); // BMP: cp already is the char, so skip the redundant charAt re-read
+ } else {
+ put(nonPrintable.charAt(i));
+ put(nonPrintable.charAt(i + 1));
+ }
+ } else {
+ DisplaySafe.putUnicodeEscape(this, cp);
+ }
+ i += count;
}
}
default void putAsPrintable(char c) {
- if (c > 0x1F && c != 0x7F) {
+ // A single UTF-16 unit: escape control chars, Unicode format chars, and a lone surrogate (which has
+ // no displayable meaning). Supplementary-plane format chars are caught by the code-point-aware
+ // putAsPrintable(CharSequence).
+ if (DisplaySafe.isDisplaySafe(c)) {
put(c);
} else {
- put('\\');
- put('u');
-
- final int s = (int) c & 0xFF;
- put('0');
- put('0');
- put(hexDigits[s / 0x10]);
- put(hexDigits[s % 0x10]);
+ DisplaySafe.putUnicodeEscape(this, c);
}
}
@@ -93,5 +128,4 @@ default Utf16Sink putNonAscii(long lo, long hi) {
Utf8s.utf8ToUtf16(lo, hi, this);
return this;
}
-
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java
index ada19961c..8383221e4 100644
--- a/core/src/main/java/module-info.java
+++ b/core/src/main/java/module-info.java
@@ -27,7 +27,18 @@
requires static org.jetbrains.annotations;
requires static java.management;
requires jdk.management;
- requires java.desktop;
+ // STATIC, not mandatory: the only java.desktop reference is BrowserLauncher's java.awt.Desktop, used
+ // best-effort by the default DeviceCodePrompt.openBrowser() to pop the verification URL. A mandatory
+ // requires is resolved BEFORE any code runs, so on a runtime without java.desktop - a jlink image, a
+ // --limit-modules run - the whole module failed to resolve at startup and the LinkageError catch in
+ // openBrowser() never got the chance to degrade to "print the URL and carry on". As a static requires
+ // the dependency is compile-time only: java.desktop's absence surfaces as the NoClassDefFoundError that
+ // catch already handles. Note the consequence for a MODULAR application - a static requires is not
+ // followed during runtime resolution, so such an application only gets the browser launch when
+ // java.desktop is in its graph anyway (it requires it, or --add-modules java.desktop); everything else,
+ // including every class-path application, is unaffected because java.desktop is resolved there by
+ // default. See DeviceCodePrompt#openBrowser().
+ requires static java.desktop;
requires java.sql;
requires org.slf4j;
diff --git a/core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java
new file mode 100644
index 000000000..bda404e1c
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java
@@ -0,0 +1,82 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test;
+
+import io.questdb.client.HttpTokenProvider;
+import io.questdb.client.cutlass.line.LineSenderException;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class HttpTokenProviderTest {
+
+ @Test
+ public void testValidateTokenAcceptsPrintableAscii() {
+ // a real bearer token is printable ASCII (base64url JWT segments joined by dots); validateToken
+ // must pass it through unchanged
+ HttpTokenProvider.validateToken("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJfc3NvIn0.abc-DEF_123");
+ HttpTokenProvider.validateToken("a~b"); // 0x7e (~) is the top of the allowed range
+ HttpTokenProvider.validateToken("a b"); // an interior space (0x20) is allowed; only an all-blank token is rejected
+ }
+
+ @Test
+ public void testValidateTokenNeverEchoesTheToken() {
+ // the token is the secret this guards; it must never appear in the exception message
+ try {
+ HttpTokenProvider.validateToken("SUPERSECRET" + (char) 0x0d + (char) 0x0a + "TOKEN");
+ Assert.fail("expected the token to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("SUPERSECRET"));
+ }
+ }
+
+ @Test
+ public void testValidateTokenRejectsBlank() {
+ assertRejected(null, "null or empty token");
+ assertRejected("", "null or empty token");
+ assertRejected(" ", "null or empty token");
+ }
+
+ @Test
+ public void testValidateTokenRejectsControlOrNonAscii() {
+ // a control char would break out of the "Authorization: Bearer " header (CR/LF injects into
+ // the request line); a non-ASCII char is silently truncated to one byte by the ASCII header writer.
+ // The strings are built with explicit char values to keep this source pure ASCII.
+ assertRejected("abc" + (char) 0x0d + (char) 0x0a + "def", "control or non-ASCII character"); // CR/LF
+ assertRejected("tok" + (char) 0x00 + "en", "control or non-ASCII character"); // NUL
+ assertRejected((char) 0x1b + "[31mred", "control or non-ASCII character"); // ANSI escape (ESC)
+ assertRejected("a" + (char) 0x1f + "b", "control or non-ASCII character"); // 0x1f, just below the 0x20 lower bound
+ assertRejected("a" + (char) 0x7f + "b", "control or non-ASCII character"); // DEL (0x7f), just above the 0x7e upper bound
+ assertRejected("tok" + (char) 0xe9 + "n", "control or non-ASCII character"); // non-ASCII (e-acute, 0xe9)
+ }
+
+ private static void assertRejected(CharSequence token, String expectedMessage) {
+ try {
+ HttpTokenProvider.validateToken(token);
+ Assert.fail("expected token to be rejected: " + token);
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage));
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
index 6cf5eb09a..e338aa72c 100644
--- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
+++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
@@ -24,13 +24,20 @@
package io.questdb.client.test;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.QuestDB;
import io.questdb.client.QuestDBBuilder;
+import io.questdb.client.Query;
+import io.questdb.client.Sender;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import org.junit.Assert;
import org.junit.Test;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
public class QuestDBBuilderTest {
@@ -79,6 +86,118 @@ public void testConnectSingleStringValidatesAndBuilds() {
}
}
+ @Test
+ public void testConnectTokenProviderSuppliesBothPoolsAndPoolGrowth() throws Exception {
+ try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ })) {
+ server.setSendServerInfo(true);
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ AtomicInteger tokenCalls = new AtomicInteger();
+ HttpTokenProvider provider = () -> "ROTATING-" + tokenCalls.incrementAndGet();
+ String cfg = "ws::addr=localhost:" + server.getPort() + ";"
+ + "sender_pool_min=1;sender_pool_max=2;"
+ + "query_pool_min=1;query_pool_max=2;"
+ + "auth_timeout_ms=2000;";
+
+ try (QuestDB db = QuestDB.connect(cfg, provider)) {
+ // Each prewarmed connection must obtain its own current token.
+ // Pool startup order is deliberately not part of the contract.
+ assertAuthorizationHeaders(
+ server,
+ "Bearer ROTATING-1",
+ "Bearer ROTATING-2");
+
+ // Exhaust each prewarmed slot so the elastic pools grow. The
+ // newly created sender and query client must pull again rather
+ // than reuse either token captured during prewarm.
+ try (Sender sender1 = db.borrowSender(); Sender sender2 = db.borrowSender()) {
+ Assert.assertNotNull(sender1);
+ Assert.assertNotNull(sender2);
+ assertAuthorizationHeaders(server, "Bearer ROTATING-3");
+ }
+ try (Query query1 = db.borrowQuery(); Query query2 = db.borrowQuery()) {
+ Assert.assertNotNull(query1);
+ Assert.assertNotNull(query2);
+ assertAuthorizationHeaders(server, "Bearer ROTATING-4");
+ }
+ }
+ Assert.assertEquals(4, tokenCalls.get());
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testEagerBuildAndBorrowQuerySurfaceAProviderFailure() throws Exception {
+ // The other half of the lazy_connect contract, and the half that must FAIL loudly: without
+ // lazy_connect the pools initialize eagerly, so a credential the provider cannot supply is a
+ // startup error the caller has to see rather than a sender that silently never authenticates.
+ // Driven against a LIVE server so the failure is unambiguously the credential and not connectivity.
+ try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ })) {
+ server.setSendServerInfo(true);
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ HttpTokenProvider failing = () -> {
+ throw new IllegalStateException("not signed in yet");
+ };
+ String eagerCfg = "ws::addr=localhost:" + server.getPort() + ";"
+ + "sender_pool_min=1;sender_pool_max=1;"
+ + "query_pool_min=1;query_pool_max=1;auth_timeout_ms=2000;";
+ try {
+ QuestDB.connect(eagerCfg, failing).close();
+ Assert.fail("an eager build must surface a provider that cannot supply a credential");
+ } catch (RuntimeException e) {
+ assertCarriesProviderCause(e);
+ }
+
+ // And the deferred read path: query_pool_min=0 prewarms nothing, so the first borrowQuery() is
+ // where the pull happens. It must report the provider's failure rather than hand back a client
+ // that never authenticated.
+ String lazyQueryCfg = "ws::addr=localhost:" + server.getPort() + ";"
+ + "sender_pool_min=0;sender_pool_max=1;"
+ + "query_pool_min=0;query_pool_max=1;auth_timeout_ms=2000;";
+ try (QuestDB db = QuestDB.connect(lazyQueryCfg, failing)) {
+ try (Query ignored = db.borrowQuery()) {
+ Assert.fail("borrowQuery() must surface a provider that cannot supply a credential");
+ } catch (RuntimeException e) {
+ assertCarriesProviderCause(e);
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testTokenProviderRejectsFixedConfigCredentialsBeforePoolCreation() {
+ HttpTokenProvider provider = () -> "TOKEN";
+ assertTokenProviderAuthRejected(
+ "ws::addr=127.0.0.1:1;token=fixed;sender_pool_min=0;query_pool_min=0;",
+ provider);
+ assertTokenProviderAuthRejected(
+ "ws::addr=127.0.0.1:1;username=user;password=pass;sender_pool_min=0;query_pool_min=0;",
+ provider);
+ }
+
+ @Test
+ public void testTokenProviderRejectsNull() {
+ try {
+ QuestDB.builder().httpTokenProvider(null);
+ Assert.fail("expected a null provider to be rejected");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("must not be null"));
+ }
+
+ try {
+ QuestDB.connect(
+ "ws::addr=127.0.0.1:1;sender_pool_min=0;query_pool_min=0;",
+ null).close();
+ Assert.fail("expected a null provider to be rejected");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("must not be null"));
+ }
+ }
+
@Test
public void testMalformedEgressConfigRejectedAtBuildWithMinZero() {
// query_pool_min=0 pre-warms nothing, so build() never constructs a
@@ -280,6 +399,43 @@ private static void assertBuildRejected(String config, String expectedFragment)
}
}
+ private static void assertCarriesProviderCause(Throwable thrown) {
+ // The provider's own message must survive to the caller: "not signed in yet" is actionable,
+ // a transport-shaped wrapper naming the endpoint is not.
+ for (Throwable t = thrown; t != null; t = t.getCause()) {
+ if (t.getMessage() != null && t.getMessage().contains("not signed in yet")) {
+ return;
+ }
+ }
+ Assert.fail("the provider's own failure must reach the caller, got: " + thrown);
+ }
+
+ private static void assertAuthorizationHeaders(
+ TestWebSocketServer server,
+ String... expected
+ ) throws InterruptedException {
+ Set actual = new HashSet<>();
+ for (int i = 0; i < expected.length; i++) {
+ String header = server.pollAuthorizationHeader(5, TimeUnit.SECONDS);
+ Assert.assertNotNull("timed out waiting for an Authorization header", header);
+ Assert.assertTrue("duplicate Authorization header: " + header, actual.add(header));
+ }
+ Assert.assertEquals(new HashSet<>(Arrays.asList(expected)), actual);
+ }
+
+ private static void assertTokenProviderAuthRejected(String config, HttpTokenProvider provider) {
+ try {
+ QuestDB.builder()
+ .fromConfig(config)
+ .httpTokenProvider(provider)
+ .build()
+ .close();
+ Assert.fail("expected fixed credentials and the token provider to be mutually exclusive");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("cannot be combined"));
+ }
+ }
+
private static void assertSchemaRejected(Runnable action) {
try {
action.run();
diff --git a/core/src/test/java/io/questdb/client/test/QuestDBLazyConnectTest.java b/core/src/test/java/io/questdb/client/test/QuestDBLazyConnectTest.java
index ef70d08a8..d140812e2 100644
--- a/core/src/test/java/io/questdb/client/test/QuestDBLazyConnectTest.java
+++ b/core/src/test/java/io/questdb/client/test/QuestDBLazyConnectTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test;
+import io.questdb.client.HttpTokenProvider;
import io.questdb.client.QuestDB;
import io.questdb.client.QuestDBBuilder;
import io.questdb.client.Sender;
@@ -31,6 +32,8 @@
import org.junit.Assert;
import org.junit.Test;
+import java.util.concurrent.atomic.AtomicInteger;
+
/**
* {@code lazy_connect=true} makes a {@link QuestDB} facade tolerate the server
* being down at startup without disabling reads: the ingest side
@@ -75,6 +78,42 @@ public void testLazyConnectStartsAndWritesWhileServerDown() {
}
}
+ @Test(timeout = 30_000)
+ public void testLazyConnectBuildsAndWritesDespiteAFailingTokenProvider() {
+ int port = TestPorts.findUnusedPort();
+ // lazy_connect and httpTokenProvider are both documented, and their COMBINATION decides who sees a
+ // credential failure at startup - but nothing drove them together. The contract: connectivity and
+ // credential errors are the caller's problem only DURING initialization, and under lazy_connect
+ // there is no eager initialization to fail. The ingest side resolves to ASYNC (client is null, no
+ // pull at build) and the read pool defaults to min=0, so build() must return and a write must
+ // buffer even though this provider can supply nothing at all.
+ //
+ // Getting this wrong is a data-loss shape, not an inconvenience: a producer that hard-fails at
+ // build() instead of buffering drops the rows store-and-forward promised to keep.
+ AtomicInteger pulls = new AtomicInteger();
+ HttpTokenProvider failing = () -> {
+ pulls.incrementAndGet();
+ throw new IllegalStateException("not signed in yet");
+ };
+ try (QuestDB db = QuestDB.connect("ws::addr=localhost:" + port
+ + ";lazy_connect=true;reconnect_max_duration_millis=200"
+ + ";reconnect_initial_backoff_millis=10;reconnect_max_backoff_millis=50"
+ + ";close_flush_timeout_millis=0;", failing)) {
+ Sender sender = db.borrowSender();
+ Assert.assertNotNull("build() must not fail-fast on a provider that cannot supply a token yet",
+ sender);
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.close();
+ } catch (RuntimeException ignored) {
+ // acceptable: the close-flush runs against a server that never came up
+ }
+ }
+ // Deliberately not asserting a pull count. The async connect thread may or may not have attempted
+ // one by now, and that timing is not the contract - what is, is that neither build() nor the write
+ // above surfaced the provider's failure to the caller.
+ }
+
@Test(timeout = 30_000)
public void testLazyConnectKeepsReadsEnabledWhileServerDown() {
int port = TestPorts.findUnusedPort();
diff --git a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java
index cccbcdab9..704705e14 100644
--- a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java
+++ b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java
@@ -230,11 +230,89 @@ public void testConnectStringRejectsConnectionListenerInboxCapacityOnNonWebSocke
@Test
public void testCategoryAndPolicyAreStillEnumerable() {
- // Cross-check that the enum surface is fully reachable from
- // user-side code via the builder import path.
- SenderError.Category c = SenderError.Category.SCHEMA_MISMATCH;
- SenderError.Policy p = SenderError.Policy.RETRIABLE;
- Assert.assertNotNull(c);
- Assert.assertNotNull(p);
+ // Cross-check that the user-facing SenderError enum surface is intact, driven by NAME strings the
+ // compiler does not resolve, so a rename or removal fails this test at RUNTIME (valueOf throws
+ // IllegalArgumentException). Using a compiled constant reference (SenderError.Category.SCHEMA_MISMATCH) as
+ // the expected value instead would only fail to COMPILE on a rename - the source, not the assertion,
+ // would break - so it would test nothing at runtime.
+ Assert.assertEquals("SCHEMA_MISMATCH", SenderError.Category.valueOf("SCHEMA_MISMATCH").name());
+ Assert.assertEquals("RETRIABLE", SenderError.Policy.valueOf("RETRIABLE").name());
+ }
+
+ @Test
+ public void testHttpTokenProviderIsMutuallyExclusiveWithOtherAuth() {
+ // a provider cannot be combined with a static token or username/password, in either order
+ try {
+ Sender.builder(Sender.Transport.HTTP).address("localhost:9000")
+ .httpToken("static").httpTokenProvider(() -> "dynamic");
+ Assert.fail("expected token-already-configured");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("token was already configured"));
+ }
+ try {
+ Sender.builder(Sender.Transport.HTTP).address("localhost:9000")
+ .httpTokenProvider(() -> "dynamic").httpToken("static");
+ Assert.fail("expected token-provider-already-configured");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("token provider was already configured"));
+ }
+ try {
+ Sender.builder(Sender.Transport.HTTP).address("localhost:9000")
+ .httpUsernamePassword("u", "p").httpTokenProvider(() -> "dynamic");
+ Assert.fail("expected username-already-configured");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("username was already configured"));
+ }
+ }
+
+ @Test
+ public void testHttpTokenProviderNullRejectedAndExclusiveWithLaterUsernamePassword() {
+ // a null provider is rejected up front
+ try {
+ Sender.builder(Sender.Transport.HTTP).address("localhost:9000").httpTokenProvider(null);
+ Assert.fail("expected a null provider to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("token provider cannot be null"));
+ }
+ // the reverse of the mutual-exclusion case above: provider first, then username/password. This hits a
+ // distinct guard in httpUsernamePassword(), which the provider-then-token / token-then-provider /
+ // username-then-provider orderings above do not reach
+ try {
+ Sender.builder(Sender.Transport.HTTP).address("localhost:9000")
+ .httpTokenProvider(() -> "dynamic").httpUsernamePassword("u", "p");
+ Assert.fail("expected token-provider-already-configured");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("token provider authentication is already configured"));
+ }
+ }
+
+ @Test
+ public void testHttpTokenProviderAcceptedForWebSocket() {
+ // the provider is supported over WebSocket (queried at each upgrade handshake): it must pass
+ // build-time validation and fail only on the connection itself, never with a "not supported"
+ // rejection. 127.0.0.1:1 is refused promptly, and InitialConnectMode defaults to OFF (fail fast).
+ try (Sender ignored = Sender.builder(Sender.Transport.WEBSOCKET).address("127.0.0.1:1")
+ .httpTokenProvider(() -> "dynamic").build()) {
+ Assert.fail("expected a connection failure against a dead address");
+ } catch (LineSenderException e) {
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("not supported for WebSocket"));
+ }
+ }
+
+ @Test
+ public void testHttpTokenProviderRejectedForTcpAndUdp() {
+ // TCP uses challenge-response key auth and UDP has no auth; neither carries a bearer token,
+ // so both must reject the provider at build time
+ assertProviderRejected(Sender.Transport.TCP, "token provider authentication is not supported for TCP protocol");
+ assertProviderRejected(Sender.Transport.UDP, "token provider authentication is not supported for UDP transport");
+ }
+
+ private static void assertProviderRejected(Sender.Transport transport, String expectedMessage) {
+ try (Sender ignored = Sender.builder(transport).address("localhost:9009")
+ .httpTokenProvider(() -> "dynamic").build()) {
+ Assert.fail("expected the token provider to be rejected for " + transport);
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage));
+ }
}
}
diff --git a/core/src/test/java/io/questdb/client/test/compat/ExportedApiCompatibilityTest.java b/core/src/test/java/io/questdb/client/test/compat/ExportedApiCompatibilityTest.java
new file mode 100644
index 000000000..8ace8e77c
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/compat/ExportedApiCompatibilityTest.java
@@ -0,0 +1,194 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.compat;
+
+import io.questdb.client.cutlass.http.client.Fragment;
+import io.questdb.client.cutlass.http.client.Response;
+import io.questdb.client.cutlass.line.http.AbstractLineHttpSender;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Pins the public signatures this branch had to restore after replacing them in place.
+ *
+ * Three exported methods were changed rather than added to - {@code Response.recv(int)} arrived as an
+ * abstract interface method, two {@code QwpWebSocketSender.connect(..., String, ...)} overloads were retyped
+ * to {@code Supplier}, and the multi-host {@code AbstractLineHttpSender.createLineSender} gained a
+ * parameter in place. All three sit in packages {@code module-info.java} exports and that ship a javadoc jar,
+ * so a caller compiled against an earlier release would have failed with {@code NoSuchMethodError}, and an
+ * external {@code Response} implementation with {@code AbstractMethodError}. Nothing in this repository, in
+ * questdb, or in questdb-enterprise calls them, which is why the break was latent rather than observed - and
+ * why nothing would have caught it coming back.
+ *
+ * There is no japicmp or revapi gate on this build, so this test is the gate. The expected signatures below
+ * are the ones present at this branch's merge base ({@code 2489b243}); they are written out literally rather
+ * than derived from the current classes, because a pin computed from the thing it pins proves nothing.
+ * Adding an overload is fine and this test stays green; retyping or removing one turns it red.
+ */
+public class ExportedApiCompatibilityTest {
+
+ /**
+ * Every {@code QwpWebSocketSender.connect} and {@code AbstractLineHttpSender.createLineSender} signature
+ * that existed at the merge base, as {@code name(paramType,...)returnType} over erased type names.
+ */
+ private static final String[] PRE_BRANCH_SIGNATURES = {
+ // ---- QwpWebSocketSender.connect ----
+ "connect(java.lang.String,int)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long,io.questdb.client.Sender$InitialConnectMode)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long,io.questdb.client.Sender$InitialConnectMode,io.questdb.client.SenderErrorHandler,int)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.lang.String,int,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long,io.questdb.client.Sender$InitialConnectMode,io.questdb.client.SenderErrorHandler,int,long)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.util.List,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long,io.questdb.client.Sender$InitialConnectMode,io.questdb.client.SenderErrorHandler,int,long,long)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.util.List,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long,io.questdb.client.Sender$InitialConnectMode,io.questdb.client.SenderErrorHandler,int,long,long,int,io.questdb.client.SenderConnectionListener,int)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ "connect(java.util.List,io.questdb.client.ClientTlsConfiguration,int,int,long,java.lang.String,boolean,io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine,long,long,long,long,io.questdb.client.Sender$InitialConnectMode,io.questdb.client.SenderErrorHandler,int,long,long,int,io.questdb.client.SenderConnectionListener,int,int,long,long)io.questdb.client.cutlass.qwp.client.QwpWebSocketSender",
+ // ---- AbstractLineHttpSender.createLineSender ----
+ "createLineSender(java.lang.String,int,java.lang.String,io.questdb.client.HttpClientConfiguration,io.questdb.client.ClientTlsConfiguration,int,java.lang.String,java.lang.String,java.lang.String,int,long,int,long,long,int)io.questdb.client.cutlass.line.http.AbstractLineHttpSender",
+ "createLineSender(io.questdb.client.std.ObjList,io.questdb.client.std.IntList,java.lang.String,io.questdb.client.HttpClientConfiguration,io.questdb.client.ClientTlsConfiguration,int,java.lang.String,java.lang.String,java.lang.String,int,long,int,long,long,int)io.questdb.client.cutlass.line.http.AbstractLineHttpSender",
+ };
+
+ @Test
+ public void testPreBranchCreateLineSenderOverloadsStillLink() {
+ assertSignaturesPresent(AbstractLineHttpSender.class, "createLineSender");
+ }
+
+ @Test
+ public void testPreBranchQwpWebSocketSenderConnectOverloadsStillLink() {
+ assertSignaturesPresent(QwpWebSocketSender.class, "connect");
+ }
+
+ @Test
+ public void testResponseRecvIntIsDefaultNotAbstract() throws Exception {
+ // The defect was recv(int) arriving as an abstract interface method: an external implementation
+ // written against the earlier Response compiles fine and then fails at run time with
+ // AbstractMethodError, which is exactly the failure a unit test of this library never sees.
+ Method recvWithTimeout = Response.class.getMethod("recv", int.class);
+ Assert.assertFalse("Response.recv(int) must stay a default method - an implementation written "
+ + "before the overload existed has no override for it",
+ Modifier.isAbstract(recvWithTimeout.getModifiers()));
+ Assert.assertEquals(Fragment.class, recvWithTimeout.getReturnType());
+
+ Method recv = Response.class.getMethod("recv");
+ Assert.assertTrue("recv() is the one method an implementation must supply",
+ Modifier.isAbstract(recv.getModifiers()));
+ Assert.assertEquals(Fragment.class, recv.getReturnType());
+ }
+
+ @Test
+ public void testResponseImplementorOverridingOnlyRecvStillWorks() {
+ // LegacyResponse below is the compile-time half: it implements Response and overrides recv() ONLY,
+ // exactly as an implementation predating the overload does. If recv(int) went back to being
+ // abstract, this class stops compiling and the whole test module goes with it - which is the point.
+ LegacyResponse legacy = new LegacyResponse();
+ Fragment first = legacy.recv();
+ Assert.assertNotNull(first);
+ Assert.assertEquals(1, legacy.calls);
+
+ // and the default must keep the PREVIOUS behaviour, not merely link: it ignores the bound and
+ // defers to recv(), which is what such an implementation did before the overload existed
+ Fragment bounded = legacy.recv(5_000);
+ Assert.assertSame("the default must delegate to recv()", legacy.fragment, bounded);
+ Assert.assertEquals("and must not read anything of its own", 2, legacy.calls);
+
+ Fragment unbounded = legacy.recv(0);
+ Assert.assertSame("a non-positive timeout is the legacy unbounded path, same delegation",
+ legacy.fragment, unbounded);
+ Assert.assertEquals(3, legacy.calls);
+ }
+
+ private static void assertSignaturesPresent(Class> type, String methodName) {
+ final Set actual = new TreeSet<>();
+ for (Method m : type.getMethods()) {
+ if (m.getName().equals(methodName)) {
+ actual.add(signatureOf(m));
+ }
+ }
+ final Set missing = new LinkedHashSet<>();
+ int expected = 0;
+ for (String signature : PRE_BRANCH_SIGNATURES) {
+ if (!signature.startsWith(methodName + "(")) {
+ continue;
+ }
+ expected++;
+ if (!actual.contains(signature)) {
+ missing.add(signature);
+ }
+ }
+ Assert.assertTrue("expected at least one pinned signature for " + methodName, expected > 0);
+ Assert.assertTrue(
+ "these " + type.getSimpleName() + '.' + methodName + " signatures existed at the merge base "
+ + "and no longer do, so a caller compiled against an earlier release breaks with "
+ + "NoSuchMethodError. Add an overload instead of retyping one.\n missing:\n "
+ + String.join("\n ", missing) + "\n present:\n "
+ + String.join("\n ", actual),
+ missing.isEmpty());
+ }
+
+ private static String signatureOf(Method m) {
+ final StringBuilder sb = new StringBuilder(m.getName()).append('(');
+ final Class>[] params = m.getParameterTypes();
+ for (int i = 0; i < params.length; i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(params[i].getName());
+ }
+ return sb.append(')').append(m.getReturnType().getName()).toString();
+ }
+
+ /**
+ * A {@link Response} written before {@code recv(int)} existed: it overrides {@code recv()} and nothing
+ * else. Its value is mostly at compile time - it does not compile against an abstract {@code recv(int)}.
+ */
+ private static final class LegacyResponse implements Response {
+ private final Fragment fragment = new Fragment() {
+ @Override
+ public long hi() {
+ return 128L;
+ }
+
+ @Override
+ public long lo() {
+ return 64L;
+ }
+ };
+ private int calls;
+
+ @Override
+ public Fragment recv() {
+ calls++;
+ return fragment;
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java
new file mode 100644
index 000000000..3fc59d6dd
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java
@@ -0,0 +1,132 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+
+/**
+ * Covers {@code BrowserLauncher}, the best-effort browser launch behind the default device-code prompt.
+ *
+ * REFLECTION, deliberately: the class and all three methods are package-private, and the only public route
+ * to them is {@code DeviceCodePrompt.openBrowser().promptUser(...)}, whose whole contract is that it does
+ * nothing observable - it swallows every failure and, on a headless machine, never launches anything either
+ * way. There is no public path to assert on, so the choice is reflection or no coverage of the scheme
+ * allowlist at all. The one behaviour that IS observable from outside is what {@code open()} does with the
+ * kill-switch, and that is pinned without reflection by
+ * {@link DesktopFreeModulePathTest#testTheBrowserKillSwitchIsHonouredByOpenItself}, which runs it where
+ * {@code java.desktop} is absent.
+ */
+public class BrowserLauncherTest {
+
+ @Test
+ public void testAcceptsHttpAndHttps() throws Exception {
+ Assert.assertNotNull(invokeSafeHttpUri("https://idp.example.com/device?user_code=ABCD"));
+ Assert.assertNotNull(invokeSafeHttpUri("http://localhost:8080/device"));
+ // the scheme allowlist is case-insensitive
+ Assert.assertNotNull(invokeSafeHttpUri("HTTPS://idp.example.com"));
+ }
+
+ @Test
+ public void testOpenIsBestEffortForRejectedUrls() throws Exception {
+ // these URLs are rejected by the scheme/parse allowlist, so open() returns at the safeHttpUri null
+ // check before touching java.awt.Desktop. Assert the rejection holds so the no-op below is provably
+ // the URL-rejection path (not an incidental headless no-op), then confirm open() tolerates each
+ // without throwing (and never launches a real browser, so the test is safe on a desktop machine too)
+ Assert.assertNull(invokeSafeHttpUri("javascript:alert(1)"));
+ Assert.assertNull(invokeSafeHttpUri("not a url"));
+ invokeOpen(null);
+ invokeOpen("javascript:alert(1)");
+ invokeOpen("not a url");
+ }
+
+ @Test
+ public void testOpenRespectsDisableProperty() throws Exception {
+ // SCOPE: this test proves the property READ flips, and nothing more. A browser launch is
+ // unobservable from here, and on a headless JVM open() is a no-op whether or not it ever consulted
+ // the flag - so the invokeOpen call below would stay green against an open() that ignored the
+ // kill-switch outright (verified: removing the gate from open() leaves every assertion in this class
+ // passing). What open() DOES with the flag is pinned by
+ // DesktopFreeModulePathTest.testTheBrowserKillSwitchIsHonouredByOpenItself, which runs it where
+ // java.desktop is absent: reaching Desktop throws there, so the two directions become distinguishable
+ // - quiet with the kill-switch off, LinkageError with it on. A VALID http(s) URL is used below so the
+ // no-op under "false" is at least not URL rejection, and so this class never pops a browser.
+ String validUrl = "https://idp.example.com/device?user_code=ABCD";
+ Assert.assertNotNull("the URL must be one open() would otherwise launch", invokeSafeHttpUri(validUrl));
+ String prop = "questdb.client.oidc.open.browser";
+ String prev = System.getProperty(prop);
+ try {
+ System.clearProperty(prop);
+ Assert.assertTrue("the browser launch must default to enabled", invokeIsBrowserOpenEnabled());
+ System.setProperty(prop, "true");
+ Assert.assertTrue("\"true\" must enable the browser launch", invokeIsBrowserOpenEnabled());
+ System.setProperty(prop, "false");
+ Assert.assertFalse("\"false\" must disable the browser launch (the kill-switch)", invokeIsBrowserOpenEnabled());
+ invokeOpen(validUrl); // kill-switch off: must return without launching and without throwing
+ } finally {
+ if (prev == null) {
+ System.clearProperty(prop);
+ } else {
+ System.setProperty(prop, prev);
+ }
+ }
+ }
+
+ @Test
+ public void testRejectsDangerousOrMalformedUrls() throws Exception {
+ // an attacker-influenced verification URI must not smuggle a non-http(s) scheme to the OS handler
+ Assert.assertNull(invokeSafeHttpUri("javascript:alert(1)"));
+ Assert.assertNull(invokeSafeHttpUri("data:text/html,"));
+ Assert.assertNull(invokeSafeHttpUri("file:///etc/passwd"));
+ Assert.assertNull(invokeSafeHttpUri("ftp://example.com/x"));
+ Assert.assertNull(invokeSafeHttpUri("not a url"));
+ Assert.assertNull(invokeSafeHttpUri("//idp.example.com/device"));
+ Assert.assertNull(invokeSafeHttpUri(""));
+ Assert.assertNull(invokeSafeHttpUri(null));
+ }
+
+ // BrowserLauncher is a package-private helper; the client is an open module, so reflection reaches its
+ // static methods without widening production visibility for the test (mirrors invokeIsLoopbackHost).
+ private static boolean invokeIsBrowserOpenEnabled() throws Exception {
+ Method m = Class.forName("io.questdb.client.cutlass.auth.BrowserLauncher").getDeclaredMethod("isBrowserOpenEnabled");
+ m.setAccessible(true);
+ return (boolean) m.invoke(null);
+ }
+
+ private static void invokeOpen(String url) throws Exception {
+ Method m = Class.forName("io.questdb.client.cutlass.auth.BrowserLauncher").getDeclaredMethod("open", String.class);
+ m.setAccessible(true);
+ m.invoke(null, url);
+ }
+
+ private static URI invokeSafeHttpUri(String url) throws Exception {
+ Method m = Class.forName("io.questdb.client.cutlass.auth.BrowserLauncher").getDeclaredMethod("safeHttpUri", String.class);
+ m.setAccessible(true);
+ return (URI) m.invoke(null, url);
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeKillSwitchMain.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeKillSwitchMain.java
new file mode 100644
index 000000000..0a061f527
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeKillSwitchMain.java
@@ -0,0 +1,99 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Proves that {@code BrowserLauncher.open} itself honours the
+ * {@code questdb.client.oidc.open.browser} kill-switch, rather than merely that
+ * {@code isBrowserOpenEnabled()} reads the property.
+ *
+ * In an ordinary JVM the two are indistinguishable: a browser launch is unobservable from a test, and on a
+ * headless machine {@code Desktop.isDesktopSupported()} answers false, so {@code open()} is a no-op whether
+ * or not it ever consulted the property. Run it where {@code java.desktop} does NOT exist and the difference
+ * becomes loud - reaching {@code Desktop} throws a {@link LinkageError}:
+ *
+ * - kill-switch OFF: {@code open()} must return before touching {@code Desktop}, so no throw;
+ * - kill-switch ON: {@code open()} must reach {@code Desktop} and the LinkageError must escape.
+ *
+ * The second half is what makes the first half mean something: without it, an {@code open()} that returned
+ * immediately for any reason at all would look like a working kill-switch.
+ *
+ * Java 8 source level and no {@code java.lang.module} API, like the test that launches it - the JDK 8
+ * release profile compiles this test tree.
+ */
+public final class DesktopFreeKillSwitchMain {
+
+ static final int EXIT_DESKTOP_REACHABLE = 2;
+ static final int EXIT_NO_LINKAGE_ERROR = 3;
+ static final int EXIT_THREW_WHILE_DISABLED = 4;
+ static final String SUCCESS_MARKER = "DESKTOP-FREE-KILL-SWITCH-OK";
+ private static final String OPEN_BROWSER_PROPERTY = "questdb.client.oidc.open.browser";
+ // a URL open() would otherwise hand to the OS: http(s), so it survives the scheme allowlist and the run
+ // reaches the Desktop call. Nothing can open it here - this JVM has no java.desktop at all.
+ private static final String VALID_URL = "https://verify.example/device?user_code=WDJB-MJHT";
+
+ private DesktopFreeKillSwitchMain() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ try {
+ Class.forName("java.awt.Desktop");
+ System.out.println("java.awt.Desktop is reachable, so neither half below proves anything");
+ System.exit(EXIT_DESKTOP_REACHABLE);
+ } catch (ClassNotFoundException expected) {
+ // desktop-free, as this run requires
+ }
+
+ final Method open = Class.forName("io.questdb.client.cutlass.auth.BrowserLauncher")
+ .getDeclaredMethod("open", String.class);
+ open.setAccessible(true);
+
+ System.setProperty(OPEN_BROWSER_PROPERTY, "false");
+ try {
+ open.invoke(null, VALID_URL);
+ } catch (InvocationTargetException e) {
+ System.out.println("open() must return at the kill-switch, before java.awt.Desktop: " + e.getCause());
+ System.exit(EXIT_THREW_WHILE_DISABLED);
+ }
+
+ System.setProperty(OPEN_BROWSER_PROPERTY, "true");
+ try {
+ open.invoke(null, VALID_URL);
+ System.out.println("open() did not reach java.awt.Desktop with the kill-switch ON, so the quiet "
+ + "run above was not the kill-switch doing its job");
+ System.exit(EXIT_NO_LINKAGE_ERROR);
+ } catch (InvocationTargetException e) {
+ if (!(e.getCause() instanceof LinkageError)) {
+ System.out.println("expected a LinkageError from the missing java.desktop, got: " + e.getCause());
+ System.exit(EXIT_NO_LINKAGE_ERROR);
+ }
+ }
+
+ System.out.println(SUCCESS_MARKER);
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeModulePathTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeModulePathTest.java
new file mode 100644
index 000000000..5ee97eee8
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeModulePathTest.java
@@ -0,0 +1,149 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.cutlass.auth.DeviceCodePrompt;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+import org.slf4j.Logger;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.security.CodeSource;
+
+/**
+ * Guards the promise {@link DeviceCodePrompt#openBrowser()} makes - that the browser launch is skipped
+ * "on a runtime without the {@code java.desktop} module" and never prevents sign-in - for the one
+ * configuration where the promise used to be unkeepable: a build of this source used as an EXPLICIT
+ * module.
+ *
+ * {@code module-info.java} declared a mandatory {@code requires java.desktop}. Mandatory requires are
+ * satisfied during module RESOLUTION, before a single line of client code runs, so on a runtime image
+ * without {@code java.desktop} the JVM failed at startup with {@code FindException} and the
+ * {@code LinkageError} catch in {@code openBrowser()} - the thing that implements the promise - never got
+ * to run. The published artifact is built on JDK 8 and carries no descriptor, so it is an automatic module
+ * and was never affected; a build from this source is.
+ *
+ * The test runs a second JVM with the client on the MODULE path and {@code --limit-modules} naming only
+ * the client, which limits the universe to the client plus the closure of its mandatory requires. A
+ * {@code requires static} is not part of that closure, so {@code java.desktop} is absent and the child
+ * proves three things at once: the module resolved without it, {@code java.awt.Desktop} really is
+ * unreachable (see {@link DesktopFreePromptMain}, which fails the run if it is not - this is what catches
+ * a revert to a mandatory requires, since the closure would then drag {@code java.desktop} back in), and
+ * the default prompt still renders the challenge and returns.
+ *
+ * Java 8 source level, and no {@code java.lang.module} API: the JDK 8 release profile compiles this test
+ * tree, only excluding {@code module-info.java}.
+ */
+public class DesktopFreeModulePathTest {
+
+ @Test(timeout = 60_000)
+ public void testTheBrowserKillSwitchIsHonouredByOpenItself() throws Exception {
+ // BrowserLauncherTest can only assert that isBrowserOpenEnabled() reads the property: a browser
+ // launch is unobservable, and on a headless JVM open() is a no-op whether or not it ever consulted
+ // the flag, so that test passes even against an open() that ignores it. Without java.desktop the two
+ // become distinguishable - see DesktopFreeKillSwitchMain, which drives both directions.
+ String output = runInDesktopFreeJvm(DesktopFreeKillSwitchMain.class);
+ Assert.assertTrue("the kill-switch is not what stopped the browser launch:\n" + output,
+ output.contains(DesktopFreeKillSwitchMain.SUCCESS_MARKER));
+ }
+
+ @Test(timeout = 60_000)
+ public void testTheModuleResolvesAndPromptsWithoutJavaDesktop() throws Exception {
+ String output = runInDesktopFreeJvm(DesktopFreePromptMain.class);
+ Assert.assertTrue("the child never reached the end of the prompt:\n" + output,
+ output.contains(DesktopFreePromptMain.SUCCESS_MARKER));
+ // the challenge itself must still have been shown - degrading to "no browser" must not degrade to
+ // "no instructions", which would leave a user with no way to sign in at all
+ Assert.assertTrue("the verification URL must still be printed:\n" + output,
+ output.contains("https://verify.example/device"));
+ Assert.assertTrue("the user code must still be printed:\n" + output, output.contains("WDJB-MJHT"));
+ }
+
+ private static File locationOf(Class> type) {
+ CodeSource source = type.getProtectionDomain().getCodeSource();
+ Assert.assertNotNull("no code source for " + type.getName(), source);
+ return new File(source.getLocation().getPath());
+ }
+
+ /**
+ * Runs {@code main} in a second JVM that has the client on the MODULE path and no {@code java.desktop},
+ * and returns its merged stdout/stderr. Asserts a clean exit, so a child that failed its own
+ * preconditions reports through its printed reason rather than through a silent skip.
+ */
+ private static String runInDesktopFreeJvm(Class> main) throws Exception {
+ File clientLocation = locationOf(DeviceCodePrompt.class);
+ Assume.assumeFalse("the module system arrived in Java 9; nothing to resolve on a Java 8 runtime",
+ "1.8".equals(System.getProperty("java.specification.version")));
+ // A JDK 8 build produces no module-info.class: the artifact is then an automatic module, which
+ // reads every observable module and is exactly the configuration this defect never reached.
+ Assume.assumeTrue("no module descriptor next to " + clientLocation + " (a JDK 8 build)",
+ new File(clientLocation, "module-info.class").isFile());
+
+ File slf4jLocation = locationOf(Logger.class); // org.slf4j is a mandatory requires of the client
+ File testClasses = locationOf(DesktopFreeModulePathTest.class);
+ String javaBin = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java";
+
+ ProcessBuilder pb = new ProcessBuilder(
+ javaBin,
+ // Second net under each child's own desktop-free check, and independent of it: should
+ // java.desktop ever be present, Desktop.isDesktopSupported() answers false in headless mode,
+ // so nothing can reach a real browser on a developer's machine. It does not weaken either
+ // test - headless changes what Desktop ANSWERS, not whether the class reference links.
+ "-Djava.awt.headless=true",
+ "--module-path", clientLocation.getPath() + File.pathSeparator + slf4jLocation.getPath(),
+ // the universe: io.questdb.client and the closure of its MANDATORY requires, and nothing
+ // else. This is what makes the child desktop-free - and what makes a mandatory
+ // `requires java.desktop` visible, because the closure would then include it.
+ "--limit-modules", "io.questdb.client",
+ // the main class runs from the class path, so the client is not a root by default
+ "--add-modules", "io.questdb.client",
+ "-classpath", testClasses.getPath(),
+ main.getName());
+ // deliberately NOT setting questdb.client.oidc.open.browser here: the kill-switch returns before
+ // BrowserLauncher touches java.awt.Desktop, so a run with it set would never reach the LinkageError
+ // these tests exercise. What keeps a browser from opening is each child's own precondition - the
+ // prompt and the launch run only in the arm that proved Desktop unreachable - plus the headless flag.
+ pb.redirectErrorStream(true);
+ Process process = pb.start();
+ String output = readFully(process.getInputStream());
+ int exitCode = process.waitFor();
+ Assert.assertEquals("the desktop-free module-path run of " + main.getSimpleName() + " failed:\n"
+ + output, 0, exitCode);
+ return output;
+ }
+
+ private static String readFully(InputStream in) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toString("UTF-8");
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreePromptMain.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreePromptMain.java
new file mode 100644
index 000000000..1248cd314
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreePromptMain.java
@@ -0,0 +1,84 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.cutlass.auth.DeviceAuthorizationChallenge;
+import io.questdb.client.cutlass.auth.DeviceCodePrompt;
+
+/**
+ * The child half of {@link DesktopFreeModulePathTest}: run by that test in a second JVM which has
+ * {@code io.questdb.client} on the MODULE path and a universe that does not contain
+ * {@code java.desktop}. Started from the class path, so this class itself is in the unnamed module and
+ * reads whatever the module graph resolved.
+ *
+ * Deliberately written to Java 8 source level, like the test that launches it: the JDK 8 release profile
+ * compiles this same test tree (it just excludes {@code module-info.java}), so a {@code ModuleLayer} or
+ * {@code java.lang.module} reference here would break that build. Reachability of {@code java.awt.Desktop}
+ * is therefore probed with {@code Class.forName}, which answers the same question.
+ */
+public final class DesktopFreePromptMain {
+
+ /**
+ * Exit code for "the precondition did not hold": {@code java.awt.Desktop} was reachable, so nothing
+ * below would have proven anything.
+ */
+ static final int EXIT_DESKTOP_REACHABLE = 2;
+ /**
+ * Printed on success. The launching test asserts on it rather than on the exit code alone, so a JVM
+ * that exited 0 without running this far cannot read as a pass.
+ */
+ static final String SUCCESS_MARKER = "DESKTOP-FREE-PROMPT-OK";
+
+ private DesktopFreePromptMain() {
+ }
+
+ public static void main(String[] args) {
+ // The precondition, checked FIRST and by itself fatal: this JVM must genuinely lack java.desktop.
+ // It is also the regression guard. --limit-modules closes over the MANDATORY requires of the
+ // module it is given, so a module-info that says "requires java.desktop" drags java.desktop back
+ // into the universe and lands here - reachable - even though the run asked for a desktop-free one.
+ try {
+ Class.forName("java.awt.Desktop");
+ System.out.println("java.awt.Desktop is reachable, so this JVM is not desktop-free: "
+ + "io.questdb.client must declare `requires static java.desktop`, not a mandatory requires");
+ System.exit(EXIT_DESKTOP_REACHABLE);
+ } catch (ClassNotFoundException expected) {
+ // Desktop-free, as intended - the module resolved without java.desktop. The prompt runs HERE, in
+ // the arm that proved it, and not after the try: this is the one place in the suite that drives
+ // the real openBrowser() with the questdb.client.oidc.open.browser kill-switch left enabled, so
+ // "we checked first" must be structural rather than a matter of statement order that a later
+ // edit could undo. A reachable Desktop can then never reach the launch below - and the test
+ // that starts this JVM passes -Djava.awt.headless=true as a second, independent net.
+ //
+ // The promise DeviceCodePrompt.openBrowser() documents: the browser open is best-effort and
+ // "skipped on a runtime without the java.desktop module", never fatal. Reaching BrowserLauncher
+ // throws a LinkageError here, which openBrowser() swallows, leaving the printed URL and code.
+ DeviceCodePrompt.openBrowser().promptUser(new DeviceAuthorizationChallenge(
+ "WDJB-MJHT", "https://verify.example/device", null, 300, 5));
+
+ System.out.println(SUCCESS_MARKER);
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java
new file mode 100644
index 000000000..7bf4df1b4
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java
@@ -0,0 +1,2274 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.cutlass.auth.FileTokenStore;
+import io.questdb.client.cutlass.auth.OidcAuthException;
+import io.questdb.client.cutlass.auth.PersistedToken;
+import io.questdb.client.cutlass.auth.TokenStore;
+import io.questdb.client.cutlass.auth.TokenStoreKey;
+import io.questdb.client.std.Os;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Coverage for {@link FileTokenStore}.
+ *
+ * PLATFORM SCOPE. CI runs Linux only, so the store's Windows-motivated arms are covered here to the extent a
+ * POSIX host can reach them, and no further:
+ *
+ * - the {@code AccessDeniedException} retry in {@code replaceTarget} - the sharing violation a Windows
+ * reader holding the target open produces - IS exercised, by denying the rename with directory
+ * permissions instead (testReplaceTargetRetriesADeniedRenameThenSucceeds and its give-up sibling). Those
+ * two skip when the process is root, which bypasses the permission bits they rely on;
+ * - the {@code UnsupportedOperationException} fallbacks around {@code FILE_ATTRS}/{@code DIR_ATTRS}
+ * ({@code createTempFile}, {@code createLockFile}, {@code ensureDirectory}, {@code restrictToOwner}) are
+ * NOT exercised. They fire only where the filesystem cannot carry POSIX permissions, which a POSIX host
+ * cannot produce without a synthetic {@code FileSystemProvider}; the suite has no such fixture and none
+ * of these tests reach them;
+ * - the {@code AtomicMoveNotSupportedException} fallback in {@code replaceTarget} is likewise
+ * unreachable here - every filesystem these tests run on supports an atomic rename.
+ *
+ * Closing the last two needs a Windows CI agent, or a filesystem-provider fixture that reports neither POSIX
+ * attributes nor atomic moves.
+ */
+public class FileTokenStoreTest {
+
+ private static final Set OWNER_ONLY_DIR_PERMS =
+ PosixFilePermissions.fromString("rwx------");
+
+ @Rule
+ public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test
+ public void testAdvancedConstructorRejectsNonPositiveTimings() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ // a non-positive acquire budget or staleness window is rejected: a tiny/zero staleness would make
+ // every freshly created lock look abandoned, so acquirers would steal each other's live locks
+ try {
+ new FileTokenStore(dir, 0, 1000);
+ Assert.fail("a zero lock acquire budget must be rejected");
+ } catch (OidcAuthException expected) {
+ // expected
+ }
+ try {
+ new FileTokenStore(dir, -1, 1000);
+ Assert.fail("a negative lock acquire budget must be rejected");
+ } catch (OidcAuthException expected) {
+ // expected
+ }
+ try {
+ new FileTokenStore(dir, 1000, 0);
+ Assert.fail("a zero lock staleness window must be rejected");
+ } catch (OidcAuthException expected) {
+ // expected
+ }
+ try {
+ new FileTokenStore(dir, 1000, -1);
+ Assert.fail("a negative lock staleness window must be rejected");
+ } catch (OidcAuthException expected) {
+ // expected
+ }
+ });
+ }
+
+ @Test
+ public void testAdvancedConstructorRejectsOverCapAcquireBudget() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ // the acquire budget is capped: getToken() can wait it out on the latency-sensitive flush path, so
+ // an unbounded budget would let a misconfiguration stall a flush; the cap also keeps a waiter
+ // degrading well before it could begin stealing live locks
+ try {
+ new FileTokenStore(dir, 30_001, 600_000);
+ Assert.fail("an over-cap lock acquire budget must be rejected");
+ } catch (OidcAuthException expected) {
+ Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("lockAcquireBudgetMillis"));
+ }
+ // the cap boundary itself is accepted
+ new FileTokenStore(dir, 30_000, 600_000);
+ });
+ }
+
+ @Test
+ public void testArrayWrappedJsonReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ // a valid entry, then the same object wrapped in a top-level array. The wrapper leaves the
+ // fingerprint fields untouched, so only the non-object-root rejection - not a fingerprint mismatch -
+ // can reject it: the parser must refuse a shape that is not a single flat JSON object
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertNotNull("the plain object must load", store.load(key));
+ byte[] obj = Files.readAllBytes(tokenFile(dir, key));
+ byte[] wrapped = new byte[obj.length + 2];
+ wrapped[0] = '[';
+ System.arraycopy(obj, 0, wrapped, 1, obj.length);
+ wrapped[wrapped.length - 1] = ']';
+ Files.write(tokenFile(dir, key), wrapped);
+ Assert.assertNull("an array-wrapped object must be rejected as a malformed shape", store.load(key));
+ });
+ }
+
+ @Test
+ public void testAudienceNullVersusEmptyFingerprint() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey nullAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, false);
+ TokenStoreKey withAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", "api://billing", false);
+
+ // a null audience round-trips: the writer omits the member, and nullableEquals matches an absent
+ // file value against a null key audience
+ store.save(nullAud, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertNotNull(store.load(nullAud));
+ byte[] nullAudBytes = Files.readAllBytes(tokenFile(dir, nullAud));
+
+ store.save(withAud, sampleToken("ACCESS-2", "REFRESH-2"));
+ byte[] withAudBytes = Files.readAllBytes(tokenFile(dir, withAud));
+
+ // place each file under the *other* key's name to isolate the in-file audience fingerprint check
+ // from the hash-based file naming: a recorded audience must not match a null-audience key, and an
+ // absent audience must not match an audience-bearing key
+ Files.write(tokenFile(dir, nullAud), withAudBytes);
+ Assert.assertNull("a recorded audience must not match a null-audience key", store.load(nullAud));
+ Files.write(tokenFile(dir, withAud), nullAudBytes);
+ Assert.assertNull("an absent audience must not match an audience-bearing key", store.load(withAud));
+ });
+ }
+
+ @Test
+ public void testClearDeletesFile() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertTrue(Files.exists(tokenFile(dir, key)));
+
+ store.clear(key);
+ Assert.assertFalse(Files.exists(tokenFile(dir, key)));
+ Assert.assertNull(store.load(key));
+ // clearing a missing entry is a no-op, not an error
+ store.clear(key);
+ });
+ }
+
+ @Test
+ public void testClearErasesTheEntryOnAnInterruptCarryingThread() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-SECRET"));
+ Assert.assertTrue(Files.exists(tokenFile(dir, key)));
+
+ // A sign-out runs on shutdown and cleanup paths, which is exactly where a thread carries an
+ // interrupt flag - the standard cancellation idiom re-asserts it. Routed through inLock, the
+ // carried flag made the delete never run, and clear() discarded the false return: the plaintext
+ // refresh token stayed on disk with no exception and no warning, and the next process start
+ // silently resumed the old identity. A local delete has nothing to abandon on a cancellation.
+ Thread.currentThread().interrupt();
+ final boolean flagSurvived;
+ try {
+ store.clear(key);
+ flagSurvived = Thread.currentThread().isInterrupted();
+ } finally {
+ Thread.interrupted(); // do not leak the flag into the next test
+ }
+
+ // erasure first: it is the claim this test exists for, so it is the one a regression must break
+ Assert.assertFalse("clear() must erase the credential even on an interrupt-carrying thread",
+ Files.exists(tokenFile(dir, key)));
+ Assert.assertNull(store.load(key));
+ Assert.assertTrue("the caller's cancellation signal must survive clear()", flagSurvived);
+ });
+ }
+
+ @Test
+ public void testClearOnEmptyStoreIsNoOp() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir(); // a non-existent subdirectory
+ FileTokenStore store = new FileTokenStore(dir);
+ // clearing an identity that was never saved must be a no-op and must not create the store directory
+ // just to run the now-locked delete
+ store.clear(sampleKey());
+ Assert.assertFalse("clear must not create the store directory", Files.exists(dir));
+ });
+ }
+
+ @Test
+ public void testConcurrentStealContentionDegradesCleanly() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ TokenStoreKey key = sampleKey();
+ // a lock abandoned by a crashed holder, backdated well past the staleness window
+ Path lock = lockFile(dir, key);
+ Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8));
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000));
+
+ // Several "processes" run the FULL inLock path against the one abandoned lock, and it must
+ // degrade CLEANLY: every contender runs its critical section (none is starved or wedged), each
+ // under a lock it actually holds, and no atomic-capture temp file leaks.
+ //
+ // SCOPE NOTE: these four contenders do NOT race at the file-lock layer. inLock() takes the
+ // in-process PROCESS_LOCKS entry for key.hash() before any lock-file logic, and that map is
+ // static, so four distinct FileTokenStore instances on one identity are serialized whatever the
+ // file lock does - the first reclaims the abandoned lock, and each of the rest finds the path free
+ // and creates its own. The genuinely concurrent capture race is
+ // testConcurrentStealersLeaveExactlyOneWinner, which drives stealIfStale directly because that is
+ // the only way to reach it inside one JVM.
+ //
+ // It deliberately does NOT assert strict mutual exclusion. stealIfStale is best-effort by design
+ // and documents a three-actor residual - a peer recreating the lock in the isStale->capture gap
+ // while a second captures that fresh live lock and a third claims the momentarily-free path, all
+ // at once - under which two holders can briefly run concurrently. That residual needs three or
+ // more contenders and degrades only to one extra token refresh (a re-prompt on a
+ // rotating-refresh-token IdP), never a torn or forged credential, since the Layer-1 atomic-rename
+ // write is independent of the lock.
+ // testSameProcessContendersSerializeAndBothStealStaleLock covers the two-contender same-JVM case
+ // (where PROCESS_LOCKS, not the file-lock capture, provides the exclusion it asserts).
+ final int threads = 4;
+ AtomicInteger ran = new AtomicInteger();
+ // the stamp on the lock file while each contender runs, so the assertions below can tell an
+ // acquisition apart from a no-op
+ List stampWhileRunning = Collections.synchronizedList(new ArrayList<>());
+ TokenStore.CriticalSection section = () -> {
+ stampWhileRunning.add(readLockStamp(lock));
+ Os.sleep(100);
+ ran.incrementAndGet();
+ return true;
+ };
+
+ // A contender that THREW instead of running would die on its own thread and leave the counts
+ // below looking like a clean degrade, so carry the first failure back to the test thread.
+ AtomicReference workerError = new AtomicReference<>();
+ Thread[] ts = new Thread[threads];
+ for (int i = 0; i < threads; i++) {
+ // a generous acquire budget so a contender waits for the lock rather than giving up early
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 60_000);
+ ts[i] = new Thread(() -> {
+ try {
+ store.inLock(key, section);
+ } catch (Throwable t) {
+ workerError.compareAndSet(null, t);
+ }
+ }, "steal-contender-" + i);
+ }
+ for (Thread t : ts) {
+ t.start();
+ }
+ for (Thread t : ts) {
+ joinOrFail(t, "a steal contender");
+ }
+
+ Assert.assertNull("a contender failed instead of running its critical section: " + workerError.get(),
+ workerError.get());
+ Assert.assertEquals("every contender must run its critical section", threads, ran.get());
+ // Teeth the run count alone does not have: threads==ran holds even with the whole acquire deleted,
+ // since inLock() runs the section lock-free when it cannot get a lock. A contender that never
+ // acquired would have run with the crashed holder's stamp still in place (or with no lock file at
+ // all), so require a live stamp - one that is neither absent nor the crashed holder's - under
+ // every critical section.
+ Assert.assertEquals(threads, stampWhileRunning.size());
+ for (String stamp : stampWhileRunning) {
+ Assert.assertNotNull("a contender ran with no lock file at all, so it never acquired one", stamp);
+ Assert.assertNotEquals("a contender ran while the crashed holder's lock was still in place",
+ "crashed-holder-stamp", stamp);
+ }
+ Assert.assertFalse("the last holder must have released its lock", Files.exists(lock));
+ assertNoCaptureTempFiles(dir, key);
+ });
+ }
+
+ @Test
+ public void testConcurrentStealersLeaveExactlyOneWinner() throws Exception {
+ assertMemoryLeak(() -> {
+ // The capture race stealIfStale is written for: several stealers judge the same abandoned lock
+ // stale at once, exactly one wins the ATOMIC_MOVE capture and drops it, and the losers take the
+ // NoSuchFileException arm and fall back to the wait rather than deleting anything. inLock() cannot
+ // reach this race inside one JVM - PROCESS_LOCKS serializes same-identity threads ahead of every
+ // lock-file syscall - so drive the steal itself. Reflection is the seam: the test tree is a
+ // separate io.questdb.client.test.* package with its own module-info, so package-private access
+ // is structurally unavailable.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8));
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000));
+
+ Method stealIfStale = FileTokenStore.class.getDeclaredMethod("stealIfStale", Path.class);
+ stealIfStale.setAccessible(true);
+
+ final int stealers = 8;
+ CyclicBarrier start = new CyclicBarrier(stealers);
+ AtomicReference failure = new AtomicReference<>();
+ Thread[] ts = new Thread[stealers];
+ for (int i = 0; i < stealers; i++) {
+ // one store per "process", as in the sibling test
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 60_000);
+ ts[i] = new Thread(() -> {
+ try {
+ start.await(10, TimeUnit.SECONDS);
+ stealIfStale.invoke(store, lock);
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ }
+ }, "steal-contender");
+ }
+ for (Thread t : ts) {
+ t.start();
+ }
+ for (Thread t : ts) {
+ joinOrFail(t, "a stealer");
+ }
+
+ Assert.assertNull("a stealer failed outright: " + failure.get(), failure.get());
+ Assert.assertFalse("the abandoned lock must be gone - one stealer captures it and drops it, and a "
+ + "loser must not restore what it never captured", Files.exists(lock));
+ assertNoCaptureTempFiles(dir, key);
+ });
+ }
+
+ @Test
+ public void testProcessLocksDoNotGrowWithTheIdentityCount() throws Exception {
+ assertMemoryLeak(() -> {
+ // TokenStoreKey is public and inLock() is public API, so a process mints as many identities as its
+ // caller needs - one per end user in a multi-tenant service. An unpruned map roots a 64-char hash
+ // plus a lock for every identity EVER SEEN, for the life of the JVM. Bound it by the identities
+ // actually in flight instead: once the last caller on an identity leaves, its entry goes.
+ Field field = FileTokenStore.class.getDeclaredField("PROCESS_LOCKS");
+ field.setAccessible(true);
+ java.util.Map, ?> locks = (java.util.Map, ?>) field.get(null);
+
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000);
+ AtomicInteger ran = new AtomicInteger();
+ final int identities = 500;
+ for (int i = 0; i < identities; i++) {
+ TokenStoreKey key = new TokenStoreKey("client-" + i, "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, false);
+ Assert.assertTrue(store.inLock(key, () -> {
+ ran.incrementAndGet();
+ // the entry has to EXIST while its critical section runs, or the lock is serializing
+ // nothing; the retirement below is only interesting because of this
+ Assert.assertFalse("the identity's lock must be held for the critical section",
+ locks.isEmpty());
+ return true;
+ }));
+ }
+ Assert.assertEquals("every identity must still have run its critical section", identities, ran.get());
+
+ // Nothing is in flight now, so nothing may be left behind. This is the assertion the old
+ // stripe-table version could not make: it asserted the table was the same ARRAY of the same
+ // LENGTH, which is true of any immutable array whether or not the code under test works.
+ Assert.assertEquals("no entry may outlive its last caller [left=" + locks + "]", 0, locks.size());
+ // and it must be usable, not merely empty: a fresh identity still serializes
+ Assert.assertTrue(store.inLock(sampleKey(), () -> true));
+ Assert.assertEquals("the fresh identity must be retired too", 0, locks.size());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testSameIdentityInDifferentDirectoriesDoesNotSerialize() throws Exception {
+ assertMemoryLeak(() -> {
+ // The mirror of testUnrelatedIdentitiesDoNotSerializeOnEachOther, along the other axis. That one
+ // varies the identity within one directory; this one keeps the identity and varies the
+ // directory - which is the shape this class's javadoc and the README actually prescribe for
+ // signing several application users in at once: "a store each, on a per-user directory".
+ //
+ // Those two stores hold DIFFERENT files, so serializing them buys nothing and costs everything
+ // the sibling test describes: the lock spans a whole token-endpoint round trip, its acquire has
+ // no budget, and getToken() sits on the ILP flush path.
+ //
+ // Same CyclicBarrier trick: it trips only when both callers are inside their critical section at
+ // once, which two callers sharing one lock can never be.
+ // distinct directories, not storeDir() twice - that helper returns one fixed path, and two
+ // stores over ONE directory are the case that must keep serializing
+ Path dirA = temp.getRoot().toPath().resolve("oidc-tokens-user-a");
+ Path dirB = temp.getRoot().toPath().resolve("oidc-tokens-user-b");
+ createStoreDir(dirA);
+ createStoreDir(dirB);
+ FileTokenStore storeA = new FileTokenStore(dirA, 30_000, 600_000);
+ FileTokenStore storeB = new FileTokenStore(dirB, 30_000, 600_000);
+ TokenStoreKey key = sampleKey();
+
+ CyclicBarrier bothInside = new CyclicBarrier(2);
+ AtomicReference workerError = new AtomicReference<>();
+ AtomicInteger ran = new AtomicInteger();
+ TokenStore.CriticalSection section = () -> {
+ try {
+ ran.incrementAndGet();
+ bothInside.await(20, TimeUnit.SECONDS);
+ return true;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ };
+
+ Thread tA = new Thread(() -> {
+ try {
+ Assert.assertTrue(storeA.inLock(key, section));
+ } catch (Throwable t) {
+ workerError.compareAndSet(null, t);
+ }
+ }, "store-a");
+ Thread tB = new Thread(() -> {
+ try {
+ Assert.assertTrue(storeB.inLock(key, section));
+ } catch (Throwable t) {
+ workerError.compareAndSet(null, t);
+ }
+ }, "store-b");
+ tA.start();
+ tB.start();
+ joinOrFail(tA, "store A");
+ joinOrFail(tB, "store B");
+
+ Assert.assertNull("one identity in two directories must not queue on a single in-process lock; "
+ + "the barrier times out when they share one", workerError.get());
+ Assert.assertEquals("both critical sections must have run", 2, ran.get());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testUnrelatedIdentitiesDoNotSerializeOnEachOther() throws Exception {
+ assertMemoryLeak(() -> {
+ // The in-process lock owes exactly one guarantee: two callers on the SAME identity must not run
+ // the read-refresh-write concurrently and double-POST a rotating refresh token. It owes unrelated
+ // identities nothing, and serializing them is not the free trade it looks - the lock is held
+ // across a whole token-endpoint round trip while the caller also holds its OidcDeviceAuth
+ // instance lock, and the acquire has no budget. One tenant's ILP flush blocking on another
+ // tenant's stalled refresh is a stall the flush path cannot see coming.
+ //
+ // MORE identities than a 64-entry stripe table has stripes, so the pigeonhole principle - not a
+ // probability - guarantees a collision under any fixed table of that size. Every identity must
+ // still be able to sit inside its critical section at once.
+ final int identities = 65;
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000);
+
+ CyclicBarrier allInside = new CyclicBarrier(identities);
+ AtomicReference workerError = new AtomicReference<>();
+ AtomicInteger inside = new AtomicInteger();
+ List workers = new ArrayList<>();
+ for (int i = 0; i < identities; i++) {
+ TokenStoreKey key = new TokenStoreKey("tenant-" + i, "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, false);
+ Thread t = new Thread(() -> {
+ try {
+ Assert.assertTrue(store.inLock(key, () -> {
+ try {
+ inside.incrementAndGet();
+ // Trips only once every identity is holding its own lock. Two identities
+ // sharing one lock can never both get here, so a stripe table deadlocks the
+ // barrier and the await below times out.
+ allInside.await(30, TimeUnit.SECONDS);
+ return true;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }));
+ } catch (Throwable e) {
+ workerError.compareAndSet(null, e);
+ allInside.reset(); // unblock the peers so the test fails loudly, not by timing out
+ }
+ }, "tenant-lock-" + i);
+ t.setDaemon(true);
+ workers.add(t);
+ t.start();
+ }
+ for (Thread t : workers) {
+ joinOrFail(t, "tenant lock holder");
+ }
+ if (workerError.get() != null) {
+ throw new AssertionError("unrelated identities did not hold their locks concurrently; "
+ + identities + " identities, " + inside.get() + " got inside", workerError.get());
+ }
+ Assert.assertEquals("every identity must have entered its critical section",
+ identities, inside.get());
+ });
+ }
+
+ @Test
+ public void testOneConfigurationHoldsOneActiveLogin() throws Exception {
+ assertMemoryLeak(() -> {
+ // The store is keyed on a CONFIGURATION - client id, endpoints, scope, audience,
+ // groups-in-token mode - and no field of TokenStoreKey names a subject. Two people signing in
+ // through the same configuration therefore address the same file, and the later sign-in
+ // overwrites the earlier one: a store holds a single active login, which is the boundary the
+ // README and the FileTokenStore javadoc now state. Anyone reading "one file per identity" as
+ // "one file per person" would size a multi-user deployment on a guarantee that does not exist.
+ FileTokenStore store = new FileTokenStore(storeDir());
+ TokenStoreKey first = sampleKey();
+ TokenStoreKey second = sampleKey(); // a separate instance, identical configuration
+ Assert.assertEquals("identical configurations must address the same entry",
+ first.hash(), second.hash());
+
+ store.save(first, sampleToken("ACCESS-ALICE", "REFRESH-ALICE"));
+ store.save(second, sampleToken("ACCESS-BOB", "REFRESH-BOB"));
+
+ PersistedToken loaded = store.load(first);
+ Assert.assertNotNull(loaded);
+ Assert.assertEquals("the later sign-in must own the entry", "ACCESS-BOB", loaded.getAccessToken());
+ Assert.assertEquals("REFRESH-BOB", loaded.getRefreshToken());
+ // and the first login is gone rather than merged or kept alongside
+ Assert.assertEquals("one configuration keeps one entry, not one per person",
+ "ACCESS-BOB", store.load(second).getAccessToken());
+
+ // separating them is the caller's job, and a separate store directory is what does it
+ Path aliceDir = temp.getRoot().toPath().resolve("alice");
+ FileTokenStore aliceStore = FileTokenStore.at(aliceDir);
+ aliceStore.save(first, sampleToken("ACCESS-ALICE", "REFRESH-ALICE"));
+ Assert.assertEquals("a per-user store keeps a per-user login",
+ "ACCESS-ALICE", aliceStore.load(first).getAccessToken());
+ Assert.assertEquals("and does not disturb the shared one",
+ "ACCESS-BOB", store.load(first).getAccessToken());
+ });
+ }
+
+ @Test
+ public void testReplaceTargetGivesUpAfterTheRetryBudget() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to deny the rename",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ Assume.assumeFalse("a root process bypasses the directory permissions this denial relies on",
+ "root".equals(System.getProperty("user.name")));
+ assertMemoryLeak(() -> {
+ // The other half of the Windows sharing-violation arm: a denial that never clears must surface,
+ // not be retried forever or swallowed. save() turns the throw into its best-effort degrade.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ Path tmp = Files.write(dir.resolve("payload.tmp"), "NEW".getBytes(StandardCharsets.UTF_8));
+ Path target = Files.write(dir.resolve("payload.json"), "OLD".getBytes(StandardCharsets.UTF_8));
+
+ Method replaceTarget = FileTokenStore.class.getDeclaredMethod("replaceTarget", Path.class, Path.class);
+ replaceTarget.setAccessible(true);
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("r-x------"));
+ try {
+ long start = System.currentTimeMillis();
+ try {
+ replaceTarget.invoke(null, tmp, target);
+ Assert.fail("a rename denied on every attempt must be reported, not swallowed");
+ } catch (InvocationTargetException e) {
+ Assert.assertTrue("the LAST denial must be the one rethrown, was: " + e.getCause(),
+ e.getCause() instanceof AccessDeniedException);
+ }
+ // 5 attempts means 4 backoff sleeps of 20ms; a shape that gave up on the first denial
+ // (the pre-retry behaviour, and what Windows would routinely trip over) returns at once
+ long elapsed = System.currentTimeMillis() - start;
+ Assert.assertTrue("it must have spent the whole retry budget, took " + elapsed + "ms",
+ elapsed >= 80);
+ } finally {
+ // restore, or the temp-folder rule cannot delete the tree
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
+ }
+ Assert.assertEquals("a failed replace must leave the previous entry intact",
+ "OLD", new String(Files.readAllBytes(target), StandardCharsets.UTF_8));
+ });
+ }
+
+ @Test
+ public void testReplaceTargetRetriesADeniedRenameThenSucceeds() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to deny the rename",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ Assume.assumeFalse("a root process bypasses the directory permissions this denial relies on",
+ "root".equals(System.getProperty("user.name")));
+ assertMemoryLeak(() -> {
+ // replaceTarget retries a denied rename because on WINDOWS a concurrent reader holding the target
+ // open makes the atomic replace fail transiently with AccessDeniedException. CI is Linux-only, so
+ // the denial is produced the one way a POSIX host can: rename(2) needs write permission on the
+ // containing directory, so taking it away denies the move exactly as the sharing violation does,
+ // and restoring it mid-retry stands in for the Windows reader closing its handle.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ Path tmp = Files.write(dir.resolve("payload.tmp"), "NEW".getBytes(StandardCharsets.UTF_8));
+ Path target = Files.write(dir.resolve("payload.json"), "OLD".getBytes(StandardCharsets.UTF_8));
+
+ Method replaceTarget = FileTokenStore.class.getDeclaredMethod("replaceTarget", Path.class, Path.class);
+ replaceTarget.setAccessible(true);
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("r-x------"));
+ // Prove the denial is real on THIS host before the test rests on it. Without this the whole test
+ // passes vacuously wherever the mode bits do not bite - the first attempt inside replaceTarget
+ // simply succeeds and no retry is ever exercised.
+ try {
+ Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ Assert.fail("the rename must be denied while the store directory is not writable");
+ } catch (AccessDeniedException expected) {
+ // exactly what a Windows sharing violation produces, and what the retry loop is written for
+ }
+ Thread reopener = new Thread(() -> {
+ // after the first backoff (20ms) but well inside the 5-attempt budget
+ Os.sleep(30);
+ try {
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
+ } catch (IOException e) {
+ throw new AssertionError("could not restore the directory permissions", e);
+ }
+ }, "denial-clearer");
+ reopener.setDaemon(true);
+ reopener.start();
+ try {
+ replaceTarget.invoke(null, tmp, target);
+ } finally {
+ reopener.join(10_000);
+ // whatever happened above, leave the tree deletable
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
+ }
+
+ Assert.assertEquals("the retry must complete the replace once the denial clears",
+ "NEW", new String(Files.readAllBytes(target), StandardCharsets.UTF_8));
+ Assert.assertFalse("an atomic move consumes the temp file", Files.exists(tmp));
+ });
+ }
+
+ @Test
+ public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ TokenStoreKey key = sampleKey();
+ // a lock abandoned by a crashed holder, backdated well past the staleness window
+ Path lock = lockFile(dir, key);
+ Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8));
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000));
+
+ // Two threads of the SAME JVM contend for the one abandoned lock. SCOPE NOTE: the mutual exclusion
+ // asserted below (overlaps==0, maxInside==1) is provided by the in-process PROCESS_LOCKS entry,
+ // which inLock() takes for key.hash() BEFORE any file-lock logic - so it would hold
+ // even if the file-lock steal were broken. What this test genuinely proves is that two same-process
+ // contenders each steal the stale lock and run their critical section (ran==2), serialized, without
+ // leaving an orphaned capture temp (assertNoCaptureTempFiles). The CROSS-process capture-verify in
+ // stealIfStale - that among separate OS PROCESSES exactly one steal wins - is masked by PROCESS_LOCKS
+ // here and cannot be exercised in a single JVM; it is verified by inspection, and a two-holder
+ // outcome is a documented best-effort residual anyway. The N-way degrade path is
+ // testConcurrentStealContentionDegradesCleanly.
+ final int threads = 2;
+ AtomicInteger inside = new AtomicInteger();
+ AtomicInteger maxInside = new AtomicInteger();
+ AtomicInteger overlaps = new AtomicInteger();
+ AtomicInteger ran = new AtomicInteger();
+ TokenStore.CriticalSection section = () -> {
+ int now = inside.incrementAndGet();
+ maxInside.accumulateAndGet(now, Math::max);
+ if (now > 1) {
+ overlaps.incrementAndGet();
+ }
+ Os.sleep(100);
+ inside.decrementAndGet();
+ ran.incrementAndGet();
+ return true;
+ };
+
+ // A contender that THREW would never enter the section, so `inside` never rises and the
+ // exclusion assertions below pass on a test that proved nothing. Carry the first failure back.
+ AtomicReference workerError = new AtomicReference<>();
+ Thread[] ts = new Thread[threads];
+ for (int i = 0; i < threads; i++) {
+ // a generous acquire budget so a contender waits for the lock rather than degrading to a
+ // lock-free run (a degraded action runs without the lock and could legitimately overlap)
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 60_000);
+ ts[i] = new Thread(() -> {
+ try {
+ store.inLock(key, section);
+ } catch (Throwable t) {
+ workerError.compareAndSet(null, t);
+ }
+ }, "same-process-contender-" + i);
+ }
+ for (Thread t : ts) {
+ t.start();
+ }
+ for (Thread t : ts) {
+ joinOrFail(t, "a same-process contender");
+ }
+
+ Assert.assertNull("a contender failed instead of running its critical section: " + workerError.get(),
+ workerError.get());
+ Assert.assertEquals("every contender must run its critical section", threads, ran.get());
+ Assert.assertEquals("same-process contenders must never overlap (PROCESS_LOCKS serializes them)", 0, overlaps.get());
+ Assert.assertEquals("at most one holder at a time", 1, maxInside.get());
+ assertNoCaptureTempFiles(dir, key);
+ });
+ }
+
+ @Test
+ public void testControlCharactersRoundTrip() throws Exception {
+ assertMemoryLeak(() -> {
+ FileTokenStore store = new FileTokenStore(storeDir());
+ TokenStoreKey key = sampleKey();
+ // a refresh token carrying every control-escape branch of the JSON writer - the short escapes
+ // (\b \f \n \r \t) and the \\u00XX arm - plus a quote and a backslash must round-trip byte for byte;
+ // the served-token char check lives in OidcDeviceAuth, so the store itself must preserve these
+ String refresh = "R\b\f\n\r\t\"\\Z";
+ // also exercise the hex-escape branch: control chars below 0x20 that are not one of the short escapes
+ refresh = refresh + (char) 0x01 + (char) 0x1f;
+ store.save(key, new PersistedToken("ACCESS-1", null, refresh, 1L, 1000L));
+
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull(loaded);
+ Assert.assertEquals("ACCESS-1", loaded.getAccessToken());
+ Assert.assertEquals(refresh, loaded.getRefreshToken());
+ });
+ }
+
+ @Test
+ public void testClearRemovesOrphanedWriteTemps() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+
+ // A crash between createTempFile and the atomic rename orphans this: it holds the FULL
+ // serialized entry - access, id and refresh tokens in plaintext. save()'s sweep only reclaims
+ // temps past the staleness window and only ever runs from save(), so a caller that cleared and
+ // never signed in again left a live refresh token on disk indefinitely, contradicting clear()'s
+ // "removes any persisted entry for this identity".
+ Path orphan = dir.resolve(key.hash() + "9999.tmp");
+ Files.write(orphan, "{\"refresh_token\":\"REFRESH-1\"}".getBytes(StandardCharsets.UTF_8));
+
+ store.clear(key);
+
+ Assert.assertFalse("clear must remove the token file", Files.exists(tokenFile(dir, key)));
+ Assert.assertFalse("clear must also reclaim an orphaned write temp holding the refresh token",
+ Files.exists(orphan));
+ });
+ }
+
+ @Test
+ public void testCorruptFileReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ createStoreDir(dir);
+ Files.write(tokenFile(dir, key), "this is not json {{{".getBytes(StandardCharsets.UTF_8));
+ Assert.assertNull(store.load(key));
+ });
+ }
+
+ @Test
+ public void testEmptyAudienceNormalizesToNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ // an empty-string audience is normalised to null: getAudience() reports null, it shares the
+ // null-audience identity hash/file, and its save->load round-trips (the pre-fix "" broke its own
+ // round-trip because the writer recorded "audience":"" but the fingerprint treated it as absent)
+ TokenStoreKey emptyAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", "", false);
+ TokenStoreKey nullAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, false);
+ Assert.assertNull("an empty audience must normalise to null", emptyAud.getAudience());
+ Assert.assertEquals("null and empty audiences must share one identity hash", nullAud.hash(), emptyAud.hash());
+
+ store.save(emptyAud, sampleToken("ACCESS-1", "REFRESH-1"));
+ PersistedToken loaded = store.load(emptyAud);
+ Assert.assertNotNull("an empty-audience key must load the entry it just saved", loaded);
+ Assert.assertEquals("ACCESS-1", loaded.getAccessToken());
+ });
+ }
+
+ @Test
+ public void testEmptyFileReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ createStoreDir(dir);
+ Files.write(tokenFile(dir, key), new byte[0]);
+ Assert.assertNull(store.load(key));
+ });
+ }
+
+ @Test
+ public void testEmptyLockStolenAfterGraceWithinStaleWindow() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // a holder that crashed between creating its lock and stamping it leaves an empty, unstamped lock.
+ // It must be reclaimable on the short empty-lock grace, not held un-stealable until the full
+ // staleness window elapses: here the staleness window is large (60s) but the empty lock is backdated
+ // only past the grace, so a steal here can only come from the empty-lock-grace path. Without that
+ // path the empty lock would not be stale (10s < 60s) and would wedge this acquirer into a lock-free
+ // degrade, leaving the lock in place.
+ FileTokenStore store = new FileTokenStore(dir, 2000, 60_000);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ Files.createFile(lock); // empty, unstamped
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 10_000));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ boolean result = store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ });
+
+ Assert.assertTrue("the action must run", ran.get());
+ Assert.assertTrue(result);
+ Assert.assertFalse("an empty (unstamped) lock past the grace must be stolen and acquired (then released),"
+ + " not wedge the acquirer for the full staleness window", Files.exists(lock));
+ });
+ }
+
+ @Test
+ public void testEmptyLockGraceIsNotShortenedByASmallStaleWindow() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // A staleness window far below the 5s empty-lock grace. The grace is the ONLY thing standing
+ // between a peer caught between its exclusive create and its stamp and having its live lock
+ // stolen, which is why the frozen cross-language contract says a client MUST NOT shorten it.
+ // Clamping the grace down to lockStaleMillis did exactly that, silently.
+ FileTokenStore store = new FileTokenStore(dir, 300, 100);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ Files.createFile(lock); // empty: a peer momentarily between its create and its stamp
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 1_000));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ });
+
+ Assert.assertTrue("inLock must still run, degrading to lock-free", ran.get());
+ Assert.assertTrue("a 1s-old empty lock is inside the 5s grace and must not be stolen",
+ Files.exists(lock));
+ Assert.assertEquals("the peer's lock must be left exactly as it was", 0, Files.size(lock));
+ });
+ }
+
+ @Test
+ public void testEnsureDirectoryTightensPreExistingDirPerms() throws Exception {
+ Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ // a pre-existing, world-accessible store directory (a permissive umask, a prior tool, or a hostile
+ // local pre-create) must be tightened to owner-only before a token is written into it
+ createStoreDir(dir);
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxrwxrwx"));
+
+ FileTokenStore store = new FileTokenStore(dir);
+ store.save(sampleKey(), sampleToken("ACCESS-1", "REFRESH-1"));
+
+ Assert.assertEquals("a pre-existing directory must be re-restricted to owner-only",
+ PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(dir));
+ });
+ }
+
+ @Test
+ public void testFingerprintMismatchReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ // save under one identity, then read with a key that hashes the same name but differs in the
+ // stored fingerprint - simulated by writing the saved bytes under a *different* key's file name
+ TokenStoreKey saved = sampleKey();
+ store.save(saved, sampleToken("ACCESS-1", "REFRESH-1"));
+ byte[] bytes = Files.readAllBytes(tokenFile(dir, saved));
+ TokenStoreKey other = new TokenStoreKey("other-client", saved.getTokenEndpoint(),
+ saved.getDeviceAuthorizationEndpoint(), saved.getScope(), null, false);
+ Files.write(tokenFile(dir, other), bytes);
+ // the file exists under other.hash(), but its in-file fingerprint says client_id=questdb, so the
+ // load for `other` must reject it rather than serve questdb's token
+ Assert.assertNull(store.load(other));
+ });
+ }
+
+ @Test
+ public void testFrozenSchemaEndpointsCarryAnExplicitPort() throws Exception {
+ assertMemoryLeak(() -> {
+ // The file NAME hash is pinned by testHashMatchesFrozenCrossLanguageContract; the file BODY was
+ // not. The two endpoint fields are part of the fingerprint and are compared with an exact string
+ // compare, not a URL compare, so they must carry the canonical rendering - port always explicit -
+ // that design/oidc-token-persistence.md specifies. A peer client (the Python one) that writes the
+ // default port implicitly produces a file this client silently ignores: load() returns null, the
+ // process re-prompts and re-persists in its own encoding, and the two never converge. That is
+ // invisible in every other test, because they all round-trip through this client's own writer.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ String withPort = "{\"v\":1,\"client_id\":\"questdb\","
+ + "\"token_endpoint\":\"https://idp.example.com:443/token\","
+ + "\"device_authorization_endpoint\":\"https://idp.example.com:443/device\","
+ + "\"scope\":\"openid\",\"groups_in_token\":false,"
+ + "\"access_token\":\"ACCESS-1\",\"refresh_token\":\"REFRESH-1\","
+ + "\"expires_at_millis\":1730000000000,\"token_ttl_millis\":300000}";
+ Files.write(tokenFile(dir, key), withPort.getBytes(StandardCharsets.UTF_8));
+ Assert.assertNotNull("the documented encoding must load", store.load(key));
+
+ // the same document with the default ports omitted - the shape a naive reading of the schema
+ // example invites - must NOT load, which is exactly why the spec pins the explicit port
+ String withoutPort = withPort
+ .replace("https://idp.example.com:443/token", "https://idp.example.com/token")
+ .replace("https://idp.example.com:443/device", "https://idp.example.com/device");
+ Files.write(tokenFile(dir, key), withoutPort.getBytes(StandardCharsets.UTF_8));
+ Assert.assertNull("an implicit default port must not match the canonical fingerprint",
+ store.load(key));
+ });
+ }
+
+ @Test
+ public void testHashMatchesFrozenCrossLanguageContract() throws Exception {
+ assertMemoryLeak(() -> {
+ // the file name is a frozen cross-language contract (the Python client mirrors it byte for byte):
+ // lowercase-hex SHA-256 of "questdb-oidc-token-v1" and the six identity fields, NUL-separated, a
+ // null audience rendered as "" and groups_in_token as '1'/'0'. Pin it to golden values so a change
+ // to the prefix, separator, field order, or null/boolean encoding that would silently stop two
+ // clients sharing one file is caught here.
+ TokenStoreKey withAudience = new TokenStoreKey("questdb",
+ "https://idp.example.com:443/as/token", "https://idp.example.com:443/as/device",
+ "openid", "api://billing", false);
+ Assert.assertEquals("eee1a742a27499d176bcdaed8635c14a3edbdef1d68b61c05c3c2158a5bfbcca", withAudience.hash());
+
+ // a null audience hashes as an empty field, not the literal "null"
+ TokenStoreKey nullAudience = new TokenStoreKey("questdb",
+ "https://idp.example.com:443/as/token", "https://idp.example.com:443/as/device",
+ "openid", null, false);
+ Assert.assertEquals("1dca0e8192ae529b94c1ac5493f09f8a45e641e4e0ec316333c0cbfeeccfef0e", nullAudience.hash());
+
+ // groups_in_token participates in the identity, so it flips the hash to a different file
+ TokenStoreKey groups = new TokenStoreKey("questdb",
+ "https://idp.example.com:443/as/token", "https://idp.example.com:443/as/device",
+ "openid", "api://billing", true);
+ Assert.assertEquals("5193f668130b28cd9430f5271011f1044b3b1c1e78bfc4f45d7688a3d9b1ceb0", groups.hash());
+ Assert.assertNotEquals(withAudience.hash(), groups.hash());
+ });
+ }
+
+ @Test
+ public void testKeyIsUsableAsAMapKey() throws Exception {
+ assertMemoryLeak(() -> {
+ // TokenStore's contract says entries are keyed by TokenStoreKey, and its javadoc invites a
+ // custom store backed by a keychain or a vault. Without value equality that reads as an
+ // invitation to a Map that never hits: OidcDeviceAuth builds its key once per instance, so a
+ // Map-backed store looks correct until a second instance - or a restart - rebuilds an equal key,
+ // misses, and sends the user back through the device flow on every refresh. The bundled
+ // FileTokenStore is unaffected only because it keys by hash() for the file name.
+ TokenStoreKey a = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid groups", "api://billing", true);
+ TokenStoreKey sameIdentity = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid groups", "api://billing", true);
+ TokenStoreKey otherClient = new TokenStoreKey("other", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid groups", "api://billing", true);
+
+ Assert.assertEquals("two keys naming one identity must be equal", a, sameIdentity);
+ Assert.assertEquals("equal keys must share a hashCode", a.hashCode(), sameIdentity.hashCode());
+ Assert.assertNotEquals("a different client id is a different identity", a, otherClient);
+ Assert.assertNotEquals(a, null);
+ Assert.assertNotEquals(a, "not a key");
+
+ Map byKey = new HashMap<>();
+ byKey.put(a, "entry");
+ Assert.assertEquals("a rebuilt key must find the entry the original stored", "entry",
+ byKey.get(sameIdentity));
+ Assert.assertNull("a different identity must not read another's entry", byKey.get(otherClient));
+ byKey.put(sameIdentity, "replaced");
+ Assert.assertEquals("an equal key must replace, not duplicate", 1, byKey.size());
+
+ // equality means "the same store entry", so it follows the constructor's null/empty audience
+ // normalisation rather than the raw arguments - the two below share one file, and now one
+ // Map slot too
+ TokenStoreKey emptyAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", "", false);
+ TokenStoreKey nullAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, false);
+ Assert.assertEquals("keys addressing one entry must be equal", emptyAud, nullAud);
+ Assert.assertEquals(emptyAud.hashCode(), nullAud.hashCode());
+ });
+ }
+
+ @Test
+ public void testInLockAbandonsFileLockWaitOnInterrupt() throws Exception {
+ assertMemoryLeak(() -> {
+ // The lock-file poll used Os.sleep, which catches InterruptedException, keeps sleeping to its own
+ // deadline and never re-asserts the flag - so a cancellation aimed at this wait was swallowed and
+ // the whole budget elapsed regardless. The budget maxes out at 30s, the same as QWP's close()
+ // shutdown budget, so a caller stuck here made close() time out and delegate the teardown of the
+ // native client, the cursor engine and the store-and-forward slot lock.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ // a live peer's stamped lock: not empty, so the empty-lock grace does not apply, and far inside
+ // the staleness window, so it is never stolen - the waiter can only poll
+ Files.write(lock, "live-peer-nonce".getBytes(StandardCharsets.UTF_8));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ AtomicReference result = new AtomicReference<>();
+ AtomicReference waiterError = new AtomicReference<>();
+ AtomicBoolean flagLeftSet = new AtomicBoolean();
+ Thread waiter = new Thread(() -> {
+ try {
+ result.set(store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ }));
+ flagLeftSet.set(Thread.currentThread().isInterrupted());
+ } catch (Throwable t) {
+ // without this the throw dies on this thread and the assertions below read it as
+ // "result was never set" - a null-vs-FALSE mismatch that names nothing
+ waiterError.compareAndSet(null, t);
+ }
+ }, "file-lock-waiter");
+ waiter.setUncaughtExceptionHandler((t, e) -> waiterError.compareAndSet(null, e));
+ waiter.setDaemon(true);
+ waiter.start();
+ // Read off the waiter's own stack that it is INSIDE the poll before interrupting it. The latch
+ // this replaced counted down at the top of the thread body, so it proved only that the thread had
+ // been scheduled: an interrupt landing before the call becomes a CARRIED flag, which inLock
+ // answers by returning false without ever entering the wait, and every assertion below would then
+ // pass on a path this test does not mean to exercise.
+ awaitInside(waiter, "acquireLock");
+
+ long start = System.currentTimeMillis();
+ waiter.interrupt();
+ waiter.join(10_000);
+ long elapsed = System.currentTimeMillis() - start;
+
+ Assert.assertNull("the waiter failed instead of abandoning its wait: " + waiterError.get(),
+ waiterError.get());
+ Assert.assertFalse("the waiter must not still be polling out the 30s budget", waiter.isAlive());
+ Assert.assertTrue("the interrupt must cut the poll short, took " + elapsed + "ms", elapsed < 5_000);
+ Assert.assertFalse("the refresh must not start once the wait was cancelled", ran.get());
+ Assert.assertEquals("an abandoned wait reports no refresh", Boolean.FALSE, result.get());
+ // The false above is not self-describing: a refresh that RAN and failed returns the same value.
+ // Only the restored flag separates them, and OidcDeviceAuth acts on the difference - a bare
+ // false sends signIn() into the interactive device flow (a browser, then a poll loop on Os.sleep
+ // that ignores interrupts) on a thread its owner just cancelled, and makes getToken() arm the
+ // instance-wide refresh back-off over a credential that is fine. This assertion used to require
+ // the opposite, on the reasoning that consuming the signal was "acting on it"; consuming it is
+ // what made the two cases indistinguishable.
+ Assert.assertTrue("inLock must leave the interrupt flag set when a cancellation abandoned its "
+ + "wait, or the caller cannot tell that apart from a failed refresh", flagLeftSet.get());
+ Assert.assertTrue("the peer's live lock must be left alone", Files.exists(lock));
+ });
+ }
+
+ @Test
+ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception {
+ assertMemoryLeak(() -> {
+ // The in-process lock that serializes same-identity threads was taken with lock(), which no
+ // interrupt can break. A peer thread holds it for a whole refresh round trip, so a caller behind
+ // it was unreachable by the one lever QWP's ConnectCancellation has.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore holderStore = new FileTokenStore(dir, 30_000, 600_000);
+ FileTokenStore waiterStore = new FileTokenStore(dir, 30_000, 600_000);
+ TokenStoreKey key = sampleKey();
+
+ CountDownLatch holding = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Thread holder = new Thread(() -> holderStore.inLock(key, () -> {
+ holding.countDown();
+ try {
+ release.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ return true;
+ }), "process-lock-holder");
+ holder.setDaemon(true);
+ holder.start();
+ Assert.assertTrue("the holder must enter its critical section", holding.await(5, TimeUnit.SECONDS));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ AtomicReference result = new AtomicReference<>();
+ AtomicBoolean flagAfterReturn = new AtomicBoolean();
+ AtomicReference waiterError = new AtomicReference<>();
+ Thread waiter = new Thread(() -> {
+ try {
+ result.set(waiterStore.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ }));
+ // sampled INSIDE the thread and immediately after the return, because that is the
+ // instant OidcDeviceAuth inspects it to tell "the wait was cancelled" from "the
+ // refresh ran and failed"
+ flagAfterReturn.set(Thread.currentThread().isInterrupted());
+ } catch (Throwable t) {
+ // see the sibling test: a throw here must arrive as itself, not as a missing result
+ waiterError.compareAndSet(null, t);
+ }
+ }, "process-lock-waiter");
+ waiter.setUncaughtExceptionHandler((t, e) -> waiterError.compareAndSet(null, e));
+ waiter.setDaemon(true);
+ waiter.start();
+ // inside inLock is the right point here: it is where the process lock is taken, and inLock's
+ // carried-interrupt check has already run by then, so the interrupt below is unambiguously the
+ // LIVE cancellation this test is about. See the sibling test for what the latch could not prove.
+ awaitInside(waiter, "inLock");
+
+ long start = System.currentTimeMillis();
+ waiter.interrupt();
+ waiter.join(10_000);
+ long elapsed = System.currentTimeMillis() - start;
+
+ Assert.assertNull("the waiter failed instead of abandoning its wait: " + waiterError.get(),
+ waiterError.get());
+ Assert.assertFalse("the waiter must not still be blocked on the process lock", waiter.isAlive());
+ Assert.assertTrue("the interrupt must break the process-lock wait, took " + elapsed + "ms",
+ elapsed < 5_000);
+ Assert.assertFalse("the refresh must not start once the wait was cancelled", ran.get());
+ Assert.assertEquals("an abandoned wait reports no refresh", Boolean.FALSE, result.get());
+ // Same contract as the file-lock sibling: false alone cannot be told from a failed refresh, and
+ // OidcDeviceAuth answers a failed refresh with the interactive device flow.
+ Assert.assertTrue("inLock must leave the interrupt flag set when a cancellation abandoned its "
+ + "wait, or the caller cannot tell that apart from a failed refresh",
+ flagAfterReturn.get());
+
+ release.countDown();
+ holder.join(10_000);
+ Assert.assertFalse("the holder must finish its critical section", holder.isAlive());
+ });
+ }
+
+ @Test
+ public void testInLockHonoursItsAcquireBudgetBehindALivePeerLock() throws Exception {
+ assertMemoryLeak(() -> {
+ // The budget is a PROMISE to the caller: inLock waits at most lockAcquireBudgetMillis for a peer's
+ // lock and then runs the critical section lock-free, because getToken() reaches this on an ILP
+ // producer's flush path. Anything blocking added inside the acquire breaks that promise silently -
+ // as ManagementFactory.getRuntimeMXBean().getName() did in the owner stamp, resolving the local
+ // hostname (InetAddress.getLocalHost()) for 3.2s inside a 200ms budget, once per JVM, on the first
+ // credential refresh. That one was caught end-to-end by
+ // OidcDeviceAuthPersistenceTest.testGetTokenDegradesWhenStoreLockHeld; this pins the same bound
+ // directly on the store, where such a call would live.
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir, 200, 600_000);
+ TokenStoreKey key = sampleKey();
+ // a live peer's stamped lock: neither the empty-lock grace nor the staleness steal applies, so the
+ // acquire can only poll it out and degrade
+ Files.write(lockFile(dir, key), "live-peer-nonce".getBytes(StandardCharsets.UTF_8));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ long start = System.nanoTime();
+ boolean result = store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ });
+ long elapsedMillis = (System.nanoTime() - start) / 1_000_000L;
+
+ Assert.assertTrue("a peer's lock must not stop the critical section, only unserialize it", ran.get());
+ Assert.assertTrue(result);
+ Assert.assertTrue("the whole budget must be spent polling, was " + elapsedMillis + "ms",
+ elapsedMillis >= 200);
+ // Generous, because this is a wall-clock bound on a shared machine: it must ride out a GC pause or
+ // a scheduling hiccup, while still failing on the kind of multi-second blocking call it exists to
+ // keep out of the acquire.
+ Assert.assertTrue("the acquire must degrade on its budget, not stall, was " + elapsedMillis + "ms",
+ elapsedMillis < 2_000);
+ Assert.assertTrue("the peer's live lock must be left alone", Files.exists(lockFile(dir, key)));
+ });
+ }
+
+ @Test
+ public void testInLockPreservesACarriedInterruptFlag() throws Exception {
+ assertMemoryLeak(() -> {
+ FileTokenStore store = new FileTokenStore(storeDir());
+ AtomicBoolean ran = new AtomicBoolean();
+
+ // The root cause behind clear() losing a credential, pinned on its own. The lock is FREE and
+ // uncontended here, so nothing is being waited on: a carried flag is the caller's own state and
+ // must survive. ReentrantLock.lockInterruptibly() begins with Thread.interrupted(), so testing
+ // the flag only after the acquire read it as a live cancellation and consumed it - which also
+ // made getToken() report "could not be refreshed" on a reachable endpoint while destroying the
+ // caller's signal.
+ Thread.currentThread().interrupt();
+ boolean result;
+ boolean flagSurvived;
+ try {
+ result = store.inLock(sampleKey(), () -> {
+ ran.set(true);
+ return true;
+ });
+ flagSurvived = Thread.currentThread().isInterrupted();
+ } finally {
+ Thread.interrupted(); // do not leak the flag into the next test
+ }
+
+ Assert.assertTrue("a carried interrupt must survive inLock", flagSurvived);
+ Assert.assertFalse("inLock must not start the critical section for a cancelled caller", ran.get());
+ Assert.assertFalse("and must report that the action did not run", result);
+ });
+ }
+
+ @Test
+ public void testInLockDegradesWhenDirectoryUnusable() throws Exception {
+ assertMemoryLeak(() -> {
+ // a regular file standing where the store directory's parent must be makes ensureDirectory throw
+ // IOException; inLock must still run the action lock-free rather than fail a sign-in
+ Path blocker = temp.getRoot().toPath().resolve("blocker");
+ Files.write(blocker, new byte[]{1});
+ FileTokenStore store = new FileTokenStore(blocker.resolve("oidc-tokens"));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ boolean result = store.inLock(sampleKey(), () -> {
+ ran.set(true);
+ return true;
+ });
+
+ Assert.assertTrue("must run the action even when the directory cannot be created (degrade)", ran.get());
+ Assert.assertTrue(result);
+ });
+ }
+
+ @Test
+ public void testInLockDegradesWhenHeldByFreshLock() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // small acquire budget, large staleness: a fresh foreign lock cannot be acquired or stolen
+ FileTokenStore store = new FileTokenStore(dir, 200, 60_000);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ Files.createFile(lock); // a live holder's fresh lock
+
+ AtomicBoolean ran = new AtomicBoolean();
+ long start = System.currentTimeMillis();
+ boolean result = store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ });
+ long elapsed = System.currentTimeMillis() - start;
+
+ Assert.assertTrue("must run the action even when it cannot lock (degrade)", ran.get());
+ Assert.assertTrue(result);
+ Assert.assertTrue("must not steal a fresh foreign lock", Files.exists(lock));
+ Assert.assertTrue("must wait out the acquire budget before degrading, was " + elapsed, elapsed >= 150);
+ });
+ }
+
+ @Test
+ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ TokenStoreKey key = sampleKey();
+ // two instances over one directory model two concurrent users of one identity; a generous acquire
+ // budget makes a contender wait rather than degrade, and a large staleness window stops either from
+ // stealing the other's live lock - so the two critical sections must run strictly one at a time. In a
+ // single JVM the in-process lock (keyed on the identity) is what serializes them; it stands in for the
+ // cross-process file lock that only genuinely separate processes would exercise.
+ FileTokenStore storeA = new FileTokenStore(dir, 10_000, 600_000);
+ FileTokenStore storeB = new FileTokenStore(dir, 10_000, 600_000);
+
+ AtomicInteger inside = new AtomicInteger();
+ AtomicInteger maxInside = new AtomicInteger();
+ AtomicInteger overlaps = new AtomicInteger();
+ AtomicInteger ran = new AtomicInteger();
+ AtomicReference workerError = new AtomicReference<>();
+ TokenStore.CriticalSection section = () -> {
+ int now = inside.incrementAndGet();
+ maxInside.accumulateAndGet(now, Math::max);
+ if (now > 1) {
+ overlaps.incrementAndGet();
+ }
+ Os.sleep(200);
+ inside.decrementAndGet();
+ ran.incrementAndGet();
+ return true;
+ };
+
+ // a barrier forces the two threads to genuinely contend, rather than one running and finishing before
+ // the other starts (which would satisfy the overlap check without ever exercising mutual exclusion)
+ CyclicBarrier barrier = new CyclicBarrier(2);
+ Thread tA = new Thread(() -> {
+ try {
+ barrier.await();
+ storeA.inLock(key, section);
+ } catch (Throwable t) {
+ workerError.compareAndSet(null, t);
+ }
+ });
+ Thread tB = new Thread(() -> {
+ try {
+ barrier.await();
+ storeB.inLock(key, section);
+ } catch (Throwable t) {
+ workerError.compareAndSet(null, t);
+ }
+ });
+ tA.start();
+ tB.start();
+ joinOrFail(tA, "contender A");
+ joinOrFail(tB, "contender B");
+
+ // capture a worker throwable on the main thread: without this, a contender that THREW instead of
+ // waiting would die silently and leave the other holder looking (falsely) like correct exclusion
+ Assert.assertNull("a contender thread failed instead of running its critical section", workerError.get());
+ Assert.assertEquals("both critical sections must have run", 2, ran.get());
+ Assert.assertEquals("the two critical sections must never overlap", 0, overlaps.get());
+ Assert.assertEquals("at most one holder at a time", 1, maxInside.get());
+ });
+ }
+
+ @Test
+ public void testInLockReleaseDoesNotDeleteAStolenLock() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // a tiny staleness window so our own in-progress hold is judged stale and a peer can steal it
+ FileTokenStore store = new FileTokenStore(dir, 1000, 50);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+
+ // our critical section outlives the 50ms staleness window; while we are still inside it, a peer
+ // process judges our lock stale, steals it (deletes and recreates) and writes its own owner stamp.
+ // releaseLock must verify ownership and leave the peer's live lock intact, not delete it by bare
+ // path - otherwise a third acquirer could enter alongside the peer, defeating mutual exclusion.
+ store.inLock(key, () -> {
+ Os.sleep(120);
+ try {
+ Files.deleteIfExists(lock);
+ Files.write(lock, "peer-owner-stamp".getBytes(StandardCharsets.UTF_8));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return true;
+ });
+
+ Assert.assertTrue("releaseLock must not delete a lock a peer has stolen", Files.exists(lock));
+ Assert.assertEquals("the peer's lock content must survive our release",
+ "peer-owner-stamp", new String(Files.readAllBytes(lock), StandardCharsets.UTF_8));
+ });
+ }
+
+ @Test
+ public void testInLockReleasesLockWhenActionThrows() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+
+ RuntimeException boom = new RuntimeException("action failed");
+ try {
+ store.inLock(key, () -> {
+ Assert.assertTrue("the lock must be held while the action runs", Files.exists(lock));
+ throw boom;
+ });
+ Assert.fail("the action's exception must propagate out of inLock");
+ } catch (RuntimeException e) {
+ Assert.assertSame(boom, e);
+ }
+ Assert.assertFalse("inLock must release the lock even when the action throws", Files.exists(lock));
+ });
+ }
+
+ @Test
+ public void testInLockReleasesLockWhenSectionLeavesThreadInterrupted() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+
+ // The critical section is a token refresh, and close() breaks a drainer stuck in one by
+ // interrupting its thread - so inLock's release routinely runs with the flag already set.
+ // releaseLock reads the lock's owner stamp through a FileChannel, an InterruptibleChannel:
+ // with the flag set that read throws ClosedByInterruptException, which releaseLock swallows,
+ // so the lock file survives its whole staleness window (10 minutes by default) while every
+ // peer degrades to an unserialized refresh - the rotating-refresh-token race the lock exists
+ // to prevent. Release must therefore be interrupt-neutral.
+ boolean released = store.inLock(key, () -> {
+ Assert.assertTrue("the lock must be held while the action runs", Files.exists(lock));
+ Thread.currentThread().interrupt();
+ return true;
+ });
+
+ Assert.assertTrue(released);
+ try {
+ Assert.assertFalse("inLock must release the lock even when the section leaves the thread "
+ + "interrupted", Files.exists(lock));
+ Assert.assertTrue("the caller's interrupt must be preserved, not consumed",
+ Thread.currentThread().isInterrupted());
+ } finally {
+ // never leak the flag into the rest of the suite
+ Thread.interrupted();
+ }
+ });
+ }
+
+ @Test
+ public void testInLockRunsActionAndManagesLockFile() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+
+ AtomicBoolean ran = new AtomicBoolean();
+ boolean result = store.inLock(key, () -> {
+ ran.set(true);
+ Assert.assertTrue("lock file must exist while the action runs", Files.exists(lock));
+ return true;
+ });
+
+ Assert.assertTrue(ran.get());
+ Assert.assertTrue(result);
+ Assert.assertFalse("lock file must be released after the action", Files.exists(lock));
+ });
+ }
+
+ @Test
+ public void testInLockStealsStaleLock() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // staleness threshold 100ms; the pre-created lock is backdated well past it
+ FileTokenStore store = new FileTokenStore(dir, 2000, 100);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ Files.createFile(lock);
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 10_000));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ boolean result = store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ });
+
+ Assert.assertTrue("must steal the stale lock and run", ran.get());
+ Assert.assertTrue(result);
+ Assert.assertFalse("having acquired the stolen lock, it must be released", Files.exists(lock));
+ });
+ }
+
+ @Test
+ public void testLiteralNullStringTokenRoundTrip() throws Exception {
+ assertMemoryLeak(() -> {
+ FileTokenStore store = new FileTokenStore(storeDir());
+ TokenStoreKey key = sampleKey();
+ // a token whose value is exactly the 4 characters "null" must survive the round trip: the writer
+ // omits absent fields rather than emitting a JSON null, so a present "null" is unambiguous on read
+ PersistedToken saved = new PersistedToken("null", null, "null", 1_730_000_000_000L, 300_000L);
+ store.save(key, saved);
+
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull(loaded);
+ Assert.assertEquals("null", loaded.getAccessToken());
+ Assert.assertNull("an absent id token must stay null, not become the string \"null\"", loaded.getIdToken());
+ Assert.assertEquals("null", loaded.getRefreshToken());
+ });
+ }
+
+ @Test
+ public void testLoadAndSaveSurviveACarriedInterruptFlag() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+
+ // Every file operation here goes through FileChannel, an InterruptibleChannel: a thread that
+ // merely CARRIES a set interrupt flag makes the first read or write throw
+ // ClosedByInterruptException, and the flag survives. Callers arrive that way routinely - an ILP
+ // producer on a pooled thread where interrupt is the cancellation signal, and the sender's own
+ // I/O thread, which close() interrupts to break a stuck credential pull. Neither means "abandon
+ // the token store", so the store must clear the flag around its own I/O and restore it after.
+ Thread.currentThread().interrupt();
+ try {
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertTrue("save must complete with the flag set", Thread.currentThread().isInterrupted());
+
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull("load must complete with the flag set, not throw on the channel", loaded);
+ Assert.assertEquals("ACCESS-1", loaded.getAccessToken());
+ Assert.assertEquals("REFRESH-1", loaded.getRefreshToken());
+ Assert.assertTrue("the caller's interrupt must be preserved, not consumed",
+ Thread.currentThread().isInterrupted());
+ } finally {
+ // never leak the flag into the rest of the suite
+ Thread.interrupted();
+ }
+ });
+ }
+
+ @Test
+ public void testLoadMissingReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ FileTokenStore store = new FileTokenStore(storeDir());
+ Assert.assertNull(store.load(sampleKey()));
+ });
+ }
+
+ @Test
+ public void testLoadDiscardsOnlyTheStoresOwnFilesFromAWorldWritableDirectory() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to loosen the store directory",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ // Discarding every ENTRY in an untrusted directory is right - the sibling test pins it. What the
+ // discard must not do is decide "entry" means "any .json", because the directory it is emptying
+ // is one the operator chose and may share. questdb.client.oidc.token.store.dir pointed at an
+ // existing config directory that happens to be group-writable is enough: one getToken() then
+ // deletes files the client never wrote.
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+
+ // Files a stranger owns, chosen to sit either side of the shape test: a plain name, a name that
+ // is hex but too short to be a fingerprint, a full-length hex name that is not a fingerprint of
+ // ANY key, and a foreign temp. (No uppercase-hex case: the store renders its digests lowercase,
+ // but on a case-insensitive filesystem such a name is the same file as the real entry, so the
+ // assertion would be about the filesystem rather than about the filter.)
+ Path plainJson = dir.resolve("my-important-settings.json");
+ Path shortHexJson = dir.resolve("abc123.json");
+ Path foreignTemp = dir.resolve("scratch-notes.tmp");
+ Files.write(plainJson, "{\"keep\":true}".getBytes(StandardCharsets.UTF_8));
+ Files.write(shortHexJson, "{\"keep\":true}".getBytes(StandardCharsets.UTF_8));
+ Files.write(foreignTemp, "keep".getBytes(StandardCharsets.UTF_8));
+
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxrwxrwx"));
+
+ Assert.assertNull("an entry from a directory other local users could write must not be adopted",
+ store.load(key));
+ Assert.assertFalse("the store's own entry is still discarded - that half is unchanged",
+ Files.exists(tokenFile(dir, key)));
+
+ Assert.assertTrue("a file the store never wrote must survive: " + plainJson.getFileName(),
+ Files.exists(plainJson));
+ Assert.assertTrue("a short hex name is not a 64-char fingerprint: " + shortHexJson.getFileName(),
+ Files.exists(shortHexJson));
+ Assert.assertTrue("a foreign .tmp is not a store write temp: " + foreignTemp.getFileName(),
+ Files.exists(foreignTemp));
+ });
+ }
+
+ @Test
+ public void testLoadRejectsAndDiscardsAnEntryFromAWorldWritableDirectory() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to loosen the store directory",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertNotNull("baseline: an entry written into an owner-only directory is trusted",
+ store.load(key));
+
+ // adopt() already rejects an entry carrying ONLY a refresh token, but a COMPLETE plant - a dummy
+ // access token, the attacker's refresh token, an expiry already in the past - takes the normal
+ // path and the next silent refresh presents their credential. Closing that needs the container
+ // checked too: an entry sitting in a directory other local users can WRITE was never ours to
+ // trust, whatever it contains.
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxrwxrwx"));
+
+ Assert.assertNull("an entry from a directory other local users could write must not be adopted",
+ store.load(key));
+ Assert.assertFalse("the untrusted entry must be discarded, not left for the next load to adopt",
+ Files.exists(tokenFile(dir, key)));
+ Assert.assertEquals("load() must tighten the store directory, as the write paths already do",
+ PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(dir));
+ Assert.assertNull(store.load(key));
+
+ // the store stays usable: a fresh sign-in persists and loads normally over the tightened directory
+ store.save(key, sampleToken("ACCESS-2", "REFRESH-2"));
+ PersistedToken reloaded = store.load(key);
+ Assert.assertNotNull(reloaded);
+ Assert.assertEquals("REFRESH-2", reloaded.getRefreshToken());
+ });
+ }
+
+ @Test
+ public void testWorldWritableVerdictIsNotConsumedByWhicheverIdentityLoadsFirst() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to loosen the store directory",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ // restrictToOwner reads the permissions, chmods to 0700, and returns the verdict it computed
+ // BEFORE the chmod - so the verdict is destroyed by the act of reporting it. One store directory
+ // holds one file per configuration, and identity A's load tightens the directory for everybody:
+ // by the time identity B loads, the directory is 0700, B's verdict is "trusted", and B adopts
+ // whatever .json happens to be sitting there. A discarded its own entry and left B's.
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey a = sampleKey();
+ TokenStoreKey b = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid profile", null, false);
+ Assert.assertNotEquals("the two identities must address different files", a.hash(), b.hash());
+
+ store.save(a, sampleToken("ACCESS-A", "REFRESH-A"));
+ store.save(b, sampleToken("ACCESS-B", "REFRESH-B"));
+
+ // the window: while this stands, any local user can replace either entry
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxrwxrwx"));
+ byte[] planted = Files.readAllBytes(tokenFile(dir, b));
+ Files.write(tokenFile(dir, b),
+ new String(planted, StandardCharsets.UTF_8)
+ .replace("REFRESH-B", "REFRESH-PLANTED")
+ .getBytes(StandardCharsets.UTF_8));
+
+ // A loads first and correctly refuses - and tightens the directory on the way through
+ Assert.assertNull("A must refuse an entry from a world-writable directory", store.load(a));
+ Assert.assertEquals("A's load tightens the directory for every later caller",
+ PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(dir));
+
+ // B now loads over a directory that LOOKS owner-only, because A made it so
+ Assert.assertNull("B must not adopt an entry that was exposed in the same window, merely "
+ + "because A's load already spent the directory's untrusted verdict", store.load(b));
+ Assert.assertFalse("and the exposed entry must be discarded, not left for the next load",
+ Files.exists(tokenFile(dir, b)));
+
+ // the store stays usable for both identities over the tightened directory
+ store.save(a, sampleToken("ACCESS-A2", "REFRESH-A2"));
+ store.save(b, sampleToken("ACCESS-B2", "REFRESH-B2"));
+ Assert.assertEquals("REFRESH-A2", store.load(a).getRefreshToken());
+ Assert.assertEquals("REFRESH-B2", store.load(b).getRefreshToken());
+ });
+ }
+
+ @Test
+ public void testWorldWritableVerdictSurvivesASaveTouchingTheDirectoryFirst() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to loosen the store directory",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ // The same verdict, spent by a WRITE path instead. save() and inLock() call ensureDirectory too
+ // and discarded its boolean outright, so a save arriving before any load tightened the directory
+ // and left every planted entry in it looking like it had always been protected.
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey a = sampleKey();
+ TokenStoreKey b = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid profile", null, false);
+ store.save(b, sampleToken("ACCESS-B", "REFRESH-B"));
+
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxrwxrwx"));
+ byte[] planted = Files.readAllBytes(tokenFile(dir, b));
+ Files.write(tokenFile(dir, b),
+ new String(planted, StandardCharsets.UTF_8)
+ .replace("REFRESH-B", "REFRESH-PLANTED")
+ .getBytes(StandardCharsets.UTF_8));
+
+ // a save for an unrelated identity is the first thing to touch the directory
+ store.save(a, sampleToken("ACCESS-A", "REFRESH-A"));
+ Assert.assertEquals("the save tightens the directory, as it always did",
+ PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(dir));
+
+ Assert.assertNull("an entry exposed before that save must not be adopted afterwards",
+ store.load(b));
+ });
+ }
+
+ @Test
+ public void testLoadThrowsRatherThanReportsEmptyWhenTheDirectoryIsUnusable() throws Exception {
+ assertMemoryLeak(() -> {
+ // Same fixture as testInLockDegradesWhenDirectoryUnusable: a regular file standing where the
+ // store directory's parent must be makes ensureDirectory throw IOException. That fault is
+ // TRANSIENT in the field - a home directory not mounted yet, EIO/ESTALE on an NFS home, a
+ // momentarily read-only or full filesystem.
+ Path blocker = temp.getRoot().toPath().resolve("blocker");
+ Files.write(blocker, new byte[]{1});
+ FileTokenStore store = new FileTokenStore(blocker.resolve("oidc-tokens"));
+
+ try {
+ PersistedToken token = store.load(sampleKey());
+ Assert.fail("load must not report a definitive empty store for a transient directory fault; "
+ + "returned " + token);
+ } catch (OidcAuthException expected) {
+ Assert.assertTrue(expected.getMessage(),
+ expected.getMessage().contains("could not prepare the OIDC token store directory"));
+ }
+ // Why the distinction is not cosmetic: null is load()'s DEFINITIVE answer. OidcDeviceAuth
+ // latches storeLoadAttempted on it and never reads the store again for the life of the
+ // instance, so a momentary mount fault at the first getToken() would send a process that owns a
+ // good refresh token back through the interactive device flow - a hard failure for the headless
+ // consumer this persistence exists to serve. A throw is retried under the store-load back-off.
+ // save() already lets this same exception propagate; only load() disagreed.
+ });
+ }
+
+ @Test
+ public void testLoadTrustsAWorldREADABLEDirectory() throws Exception {
+ Assume.assumeTrue("POSIX permissions are needed to loosen the store directory",
+ FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ // The 0755 a default umask produces is NOT the attack surface: no other user can create or
+ // replace a file in it, and the entry itself is 0600. Distrusting it would discard honest tokens
+ // - and make every negative assertion in this suite pass for the wrong reason.
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxr-xr-x"));
+
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull("a merely world-READABLE directory must not invalidate its entry", loaded);
+ Assert.assertEquals("REFRESH-1", loaded.getRefreshToken());
+ Assert.assertEquals("and it is still tightened on the way through",
+ PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(dir));
+ });
+ }
+
+ @Test
+ public void testLockFilePermissionsOwnerOnly() throws Exception {
+ Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ // the lock file is created owner-only too: it briefly records an owner stamp and sits beside the
+ // 0600 token file, so it must not widen the directory's exposure. Assert while the lock is held; inLock
+ // deletes it on return and propagates a thrown AssertionError after releasing it.
+ store.inLock(key, () -> {
+ try {
+ Assert.assertEquals("the lock file must be owner-only (0600)",
+ PosixFilePermissions.fromString("rw-------"), Files.getPosixFilePermissions(lock));
+ } catch (java.io.IOException e) {
+ throw new AssertionError(e);
+ }
+ return true;
+ });
+ });
+ }
+
+ @Test
+ public void testLongFieldsSerializeAsDigitsNotBareNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ // the two long fields are present, non-nullable integers, so Long.MIN_VALUE must serialize as its
+ // digits. serialize() reserves an omitted member (not a bare null) for an absent value, so a null
+ // here would be indistinguishable from absent and breaks the frozen cross-language contract; the
+ // reader would also round-trip that null back to 0 (parseLongOrZero), a silent corruption.
+ store.save(key, new PersistedToken("ACCESS-1", null, "REFRESH-1", Long.MIN_VALUE, Long.MIN_VALUE));
+
+ String json = new String(Files.readAllBytes(tokenFile(dir, key)), StandardCharsets.UTF_8);
+ Assert.assertTrue("expires_at_millis must be written as digits, not a bare null [json=" + json + ']',
+ json.contains("\"expires_at_millis\":-9223372036854775808"));
+ Assert.assertTrue("token_ttl_millis must be written as digits, not a bare null [json=" + json + ']',
+ json.contains("\"token_ttl_millis\":-9223372036854775808"));
+
+ // and the extreme value round-trips verbatim rather than collapsing to 0 on read
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull(loaded);
+ Assert.assertEquals(Long.MIN_VALUE, loaded.getExpiresAtMillis());
+ Assert.assertEquals(Long.MIN_VALUE, loaded.getTokenTtlMillis());
+ });
+ }
+
+ @Test
+ public void testNoLeftoverTempFileAfterSave() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ store.save(key, sampleToken("ACCESS-2", "REFRESH-2")); // overwrite
+
+ File[] files = dir.toFile().listFiles();
+ Assert.assertNotNull(files);
+ int jsonCount = 0;
+ for (File f : files) {
+ Assert.assertFalse("leftover temp file: " + f.getName(), f.getName().endsWith(".tmp"));
+ if (f.getName().endsWith(".json")) {
+ jsonCount++;
+ }
+ }
+ Assert.assertEquals(1, jsonCount);
+ });
+ }
+
+ @Test
+ public void testOutOfContractNumberIsRejectedNotQuietlyParsed() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Path file = tokenFile(dir, key);
+ String json = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+
+ // QuestDB's Numbers.parseLong accepts an 'L' suffix and '_' thousands separators. JSON allows
+ // neither, and neither does the frozen cross-language format - so "1L" is a schema version only
+ // THIS client can read, and accepting it would let a file diverge silently from every other
+ // language client sharing the directory. It must read as unusable instead.
+ String tampered = json.replace("\"v\":1,", "\"v\":1L,");
+ Assert.assertNotEquals("the fixture must actually have been tampered with", json, tampered);
+ Files.write(file, tampered.getBytes(StandardCharsets.UTF_8));
+
+ Assert.assertNull("a number only this client's parser accepts must not satisfy the schema gate",
+ store.load(key));
+ });
+ }
+
+ @Test
+ public void testOversizedFileReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ // Baseline: a normal, fingerprint-matching token loads. This proves the oversized file below is
+ // rejected by the size cap ALONE, not by a fingerprint/version/parse mismatch (the flaw in the old
+ // all-spaces file, which parsed to version 0 and would return null even with the cap removed).
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertNotNull("a normal valid token must load", store.load(key));
+ // A valid, fingerprint-matching token whose two large fields push the FILE past MAX_FILE_BYTES
+ // (1 MiB) while each field stays under the per-value lexer limit (also 1 MiB), so without the size
+ // cap this parses and loads. readBounded caps on channel.size() before reading, so the oversized
+ // file is rejected up front - the real point of the cap (avoid an unbounded read / OOM on an
+ // attacker-grown file), which the guard now demonstrably enforces.
+ char[] big = new char[600_000];
+ Arrays.fill(big, 'a');
+ String bigField = new String(big);
+ store.save(key, sampleToken(bigField, bigField));
+ Assert.assertTrue("the test file must exceed the 1 MiB size cap to isolate it",
+ Files.size(tokenFile(dir, key)) > (1 << 20));
+ Assert.assertNull("an oversized but otherwise valid, fingerprint-matching file must be rejected by the size cap",
+ store.load(key));
+ });
+ }
+
+ @Test
+ public void testOversizedStaleLockIsStolen() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // Stale window 60s, lock backdated only 10s: a STAMPED (readable) lock this fresh would NOT be
+ // stolen (10s < 60s). So the steal below can only happen because the oversized lock reads as
+ // unreadable/null via MAX_LOCK_FILE_BYTES and is stolen on the shorter empty-lock grace (5s < 10s).
+ // This isolates the read cap: remove it and readLockHolder reads the 64 KiB as a live stamp -> the
+ // lock is judged fresh, not stolen, acquisition degrades lock-free, and the lock file is NOT
+ // released, failing the Files.exists assertion below. The old 100ms window stole on staleness
+ // regardless of the cap, so the cap was untested.
+ FileTokenStore store = new FileTokenStore(dir, 2000, 60_000);
+ TokenStoreKey key = sampleKey();
+ Path lock = lockFile(dir, key);
+ // a corrupt/hostile lock far larger than the read cap. The steal reads the owner stamp with a hard
+ // cap (not Files.readAllBytes, which on an attacker-grown lock could OutOfMemoryError on the refresh
+ // path): a bounded read reports an oversized lock as unreadable, which the steal treats as abandoned
+ // junk - it must still acquire, not wedge.
+ byte[] huge = new byte[64 * 1024];
+ Arrays.fill(huge, (byte) 'x');
+ Files.write(lock, huge);
+ Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 10_000));
+
+ AtomicBoolean ran = new AtomicBoolean();
+ boolean result = store.inLock(key, () -> {
+ ran.set(true);
+ return true;
+ });
+
+ Assert.assertTrue("an oversized stale lock must be stolen, not wedge acquisition", ran.get());
+ Assert.assertTrue(result);
+ Assert.assertFalse("the acquired (stolen) lock must be released", Files.exists(lock));
+ assertNoCaptureTempFiles(dir, key);
+ });
+ }
+
+ @Test
+ public void testPerFieldFingerprintMismatchReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey saved = sampleKey();
+ store.save(saved, sampleToken("ACCESS-1", "REFRESH-1"));
+ byte[] bytes = Files.readAllBytes(tokenFile(dir, saved));
+
+ // each key differs from the saved fingerprint in exactly one field; writing the saved bytes under
+ // the differing key's file name isolates the in-file fingerprint re-check (the file is found, but
+ // its recorded identity does not match), so a hash collision or a copied file never serves another
+ // identity's token. groups_in_token (id-token vs access-token credential) and audience (the
+ // distinct nullableEquals path) are the riskiest fields.
+ TokenStoreKey[] mismatches = {
+ new TokenStoreKey("questdb", "https://idp.example.com:443/OTHER-token",
+ "https://idp.example.com:443/device", "openid", null, false),
+ new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/OTHER-device", "openid", null, false),
+ new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid groups", null, false),
+ new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", "api://other", false),
+ new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, true),
+ };
+ for (TokenStoreKey other : mismatches) {
+ Files.write(tokenFile(dir, other), bytes);
+ Assert.assertNull("a fingerprint mismatch must be rejected: " + other.hash(), store.load(other));
+ }
+ });
+ }
+
+ @Test
+ public void testPermissionsOwnerOnly() throws Exception {
+ Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertEquals(PosixFilePermissions.fromString("rw-------"),
+ Files.getPosixFilePermissions(tokenFile(dir, key)));
+ Assert.assertEquals(PosixFilePermissions.fromString("rwx------"),
+ Files.getPosixFilePermissions(dir));
+ });
+ }
+
+ @Test
+ public void testSaveFailureLeavesNoTempFileAndThrows() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ // make the atomic rename fail: the target path already exists as a NON-EMPTY directory, which a
+ // file-over-directory replace cannot overwrite, driving save() down its IOException path
+ Path target = tokenFile(dir, key);
+ Files.createDirectories(target);
+ Files.createFile(target.resolve("blocker"));
+
+ try {
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.fail("save must throw when it cannot replace the target");
+ } catch (OidcAuthException expected) {
+ // the write-temp / flush / atomic-rename protocol must surface a wrapped failure, never a raw
+ // IOException, and never a half-written credential
+ }
+
+ // the temp file is the durability point of the protocol; a failed save must clean it up rather than
+ // leave a *.tmp credential fragment behind
+ boolean hasTmp;
+ try (java.nio.file.DirectoryStream entries = Files.newDirectoryStream(dir, "*.tmp")) {
+ hasTmp = entries.iterator().hasNext();
+ }
+ Assert.assertFalse("a failed save must not leave a *.tmp file behind", hasTmp);
+ });
+ }
+
+ @Test
+ public void testSaveThenLoadRoundTrip() throws Exception {
+ assertMemoryLeak(() -> {
+ FileTokenStore store = new FileTokenStore(storeDir());
+ TokenStoreKey key = sampleKey();
+ PersistedToken saved = new PersistedToken("ACCESS-1", "ID-1", "REFRESH-1", 1_730_000_000_000L, 300_000L);
+ store.save(key, saved);
+
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull(loaded);
+ Assert.assertEquals("ACCESS-1", loaded.getAccessToken());
+ Assert.assertEquals("ID-1", loaded.getIdToken());
+ Assert.assertEquals("REFRESH-1", loaded.getRefreshToken());
+ Assert.assertEquals(1_730_000_000_000L, loaded.getExpiresAtMillis());
+ Assert.assertEquals(300_000L, loaded.getTokenTtlMillis());
+ });
+ }
+
+ @Test
+ public void testSchemaVersionMismatchReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ createStoreDir(dir);
+ // a future schema version with an otherwise-matching fingerprint must be ignored, not served: the
+ // version is the forward-compat guard the frozen cross-language contract rests on
+ String v2 = "{\"v\":2,\"client_id\":\"questdb\","
+ + "\"token_endpoint\":\"https://idp.example.com:443/token\","
+ + "\"device_authorization_endpoint\":\"https://idp.example.com:443/device\","
+ + "\"scope\":\"openid\",\"groups_in_token\":false,"
+ + "\"access_token\":\"ACCESS-1\",\"refresh_token\":\"REFRESH-1\","
+ + "\"expires_at_millis\":1730000000000,\"token_ttl_millis\":300000}";
+ Files.write(tokenFile(dir, key), v2.getBytes(StandardCharsets.UTF_8));
+ Assert.assertNull("a future schema version must be rejected", store.load(key));
+
+ // sanity: the identical body at the live version IS accepted, proving the rejection is the version
+ // and not a malformed document
+ Files.write(tokenFile(dir, key), v2.replace("\"v\":2", "\"v\":1").getBytes(StandardCharsets.UTF_8));
+ Assert.assertNotNull("the same body at the live schema version must load", store.load(key));
+ });
+ }
+
+ @Test
+ public void testSpecialCharactersAndNullsRoundTrip() throws Exception {
+ assertMemoryLeak(() -> {
+ FileTokenStore store = new FileTokenStore(storeDir());
+ // a non-null audience that needs JSON escaping, and null access/id tokens
+ TokenStoreKey key = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid groups", "api://q\"uote\\slash", true);
+ PersistedToken saved = new PersistedToken(null, null, "REFRESH-\t-1", 42L, 60_000L);
+ store.save(key, saved);
+
+ PersistedToken loaded = store.load(key);
+ Assert.assertNotNull(loaded);
+ Assert.assertNull(loaded.getAccessToken());
+ Assert.assertNull(loaded.getIdToken());
+ Assert.assertEquals("REFRESH-\t-1", loaded.getRefreshToken());
+ Assert.assertEquals(42L, loaded.getExpiresAtMillis());
+ });
+ }
+
+ @Test
+ public void testStaleTempFilesAreSweptOnSave() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ // 1s staleness window so the test does not have to wait
+ FileTokenStore store = new FileTokenStore(dir, 3_000, 1_000);
+ TokenStoreKey key = sampleKey();
+ // an orphan temp left by a crashed save (backdated past the staleness window) must be reaped on the
+ // next save; a fresh temp (recent mtime - a concurrent writer's) must be left untouched
+ Path staleTmp = dir.resolve(key.hash() + "stale.tmp");
+ Files.createFile(staleTmp);
+ Files.setLastModifiedTime(staleTmp, FileTime.fromMillis(System.currentTimeMillis() - 10_000));
+ Path freshTmp = dir.resolve(key.hash() + "fresh.tmp");
+ Files.createFile(freshTmp);
+
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+
+ Assert.assertFalse("a stale orphan temp must be swept on save", Files.exists(staleTmp));
+ Assert.assertTrue("a fresh temp (a concurrent writer's) must not be swept", Files.exists(freshTmp));
+ Assert.assertNotNull("the save must still succeed", store.load(key));
+ });
+ }
+
+ @Test
+ public void testSweepDoesNotDeleteStealCapturedLock() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ // a 1s staleness window so an old file is well past it
+ FileTokenStore store = new FileTokenStore(dir, 3_000, 1_000);
+ TokenStoreKey key = sampleKey();
+ store.save(key, sampleToken("ACCESS-0", "REFRESH-0")); // create the directory
+ // a steal in another process captures a stale lock by atomically renaming it to
+ // .lock..tmp; ATOMIC_MOVE preserves the stale lock's old mtime onto the capture. Even
+ // though that name matches the *.tmp write-temp glob and is past the staleness window, the
+ // save's temp-sweep must NOT delete it - it is a live cross-process steal in progress, and deleting
+ // it would destroy a lock the stealer may be about to restore to its live owner.
+ Path capture = dir.resolve(key.hash() + ".lock." + java.util.UUID.randomUUID() + ".tmp");
+ Files.write(capture, "stale-owner-stamp".getBytes(StandardCharsets.UTF_8));
+ Files.setLastModifiedTime(capture, FileTime.fromMillis(System.currentTimeMillis() - 10_000));
+
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1")); // runs sweepStaleTempFiles
+
+ Assert.assertTrue("the temp-sweep must not delete an in-flight steal-captured lock", Files.exists(capture));
+ });
+ }
+
+ @Test
+ public void testTokenStoreKeyRejectsNullRequiredFields() throws Exception {
+ assertMemoryLeak(() -> {
+ // the identity fields are required; a null must fail fast with a clear OidcAuthException rather than
+ // surface later as a raw NullPointerException inside save()/load() (audience stays optional)
+ try {
+ new TokenStoreKey(null, "https://idp/token", "https://idp/device", "openid", null, false);
+ Assert.fail("a null clientId must be rejected");
+ } catch (OidcAuthException expected) {
+ // required identity field
+ }
+ try {
+ new TokenStoreKey("questdb", null, "https://idp/device", "openid", null, false);
+ Assert.fail("a null tokenEndpoint must be rejected");
+ } catch (OidcAuthException expected) {
+ // required identity field
+ }
+ try {
+ new TokenStoreKey("questdb", "https://idp/token", null, "openid", null, false);
+ Assert.fail("a null deviceAuthorizationEndpoint must be rejected");
+ } catch (OidcAuthException expected) {
+ // required identity field
+ }
+ try {
+ new TokenStoreKey("questdb", "https://idp/token", "https://idp/device", null, null, false);
+ Assert.fail("a null scope must be rejected");
+ } catch (OidcAuthException expected) {
+ // required identity field
+ }
+ });
+ }
+
+ @Test
+ public void testTruncatedJsonReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ createStoreDir(dir);
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ // a crash mid-write on a filesystem without atomic rename, or a torn read, can leave a valid JSON
+ // prefix cut off before the closing brace. parseLast() must reject the truncated document rather
+ // than serve a half-parsed credential
+ Files.write(tokenFile(dir, key),
+ "{\"v\":1,\"client_id\":\"questdb\"".getBytes(StandardCharsets.UTF_8));
+ Assert.assertNull("a truncated-but-prefix-valid file must be ignored", store.load(key));
+ });
+ }
+
+ @Test
+ public void testVersionOverflowReturnsNull() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = storeDir();
+ FileTokenStore store = new FileTokenStore(dir);
+ TokenStoreKey key = sampleKey();
+ // a tampered version that narrows to SCHEMA_VERSION when cast to int (1 + 2^32) must not pass the
+ // schema gate: the parser keeps the version as a long and compares it as a long
+ store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
+ Assert.assertNotNull("the valid entry must load", store.load(key));
+ byte[] valid = Files.readAllBytes(tokenFile(dir, key));
+ String tampered = new String(valid, StandardCharsets.UTF_8).replace("\"v\":1", "\"v\":4294967297");
+ Files.write(tokenFile(dir, key), tampered.getBytes(StandardCharsets.UTF_8));
+ Assert.assertNull("a version that truncates to 1 as an int must be rejected", store.load(key));
+ });
+ }
+
+ private static TokenStoreKey sampleKey() {
+ return new TokenStoreKey("questdb", "https://idp.example.com:443/token",
+ "https://idp.example.com:443/device", "openid", null, false);
+ }
+
+ private static PersistedToken sampleToken(String access, String refresh) {
+ return new PersistedToken(access, null, refresh, System.currentTimeMillis() + 300_000L, 300_000L);
+ }
+
+ private void assertNoCaptureTempFiles(Path dir, TokenStoreKey key) throws Exception {
+ // a successful steal deletes its atomic-capture file and a restore moves it back; neither must leak a
+ // .lock..tmp behind (an orphan is otherwise only reclaimed by a later save's sweep)
+ try (DirectoryStream stream = Files.newDirectoryStream(dir, key.hash() + "*.tmp")) {
+ for (Path p : stream) {
+ Assert.fail("a steal must not leak a capture temp file: " + p.getFileName());
+ }
+ }
+ }
+
+ private static void awaitInside(Thread t, String method) throws InterruptedException {
+ // poll the thread's own stack for the named FileTokenStore frame: the only evidence that a helper
+ // thread has actually ENTERED the call, as opposed to having been scheduled at all
+ final long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline) {
+ for (StackTraceElement frame : t.getStackTrace()) {
+ if (FileTokenStore.class.getName().equals(frame.getClassName())
+ && method.equals(frame.getMethodName())) {
+ return;
+ }
+ }
+ Thread.sleep(5);
+ }
+ Assert.fail("the waiter never entered FileTokenStore." + method + " [state=" + t.getState() + ']');
+ }
+
+ /**
+ * Creates the store directory owner-only, the way {@code FileTokenStore} itself creates it - NOT the
+ * way the JVM's umask happens to.
+ *
+ * A fixture that calls {@code Files.createDirectories(dir)} bare inherits the umask, so on a host with
+ * a group-writable one (002, the default on the Linux CI agents) the directory arrives {@code
+ * rwxrwxr-x}. {@code load()} then reads it as a directory another local user could have planted an
+ * entry in, discards the entry and returns null BEFORE it opens the file - which fails every test whose
+ * first assertion is that a valid entry loads, and, far worse, silently satisfies every test asserting
+ * that some malformed entry does NOT load. Those pass for the wrong reason: proved by feeding
+ * {@code testCorruptFileReturnsNull} a perfectly valid document, which fails the test at umask 022 and
+ * passes it at 002.
+ *
+ * A test that wants a loose directory sets the permissions itself right after this call; that is an
+ * explicit statement rather than a property of whoever ran the build.
+ */
+ private static void createStoreDir(Path dir) throws Exception {
+ if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
+ Files.createDirectories(dir); // Windows: no POSIX bits to set, and load() trusts it either way
+ return;
+ }
+ Files.createDirectories(dir, PosixFilePermissions.asFileAttribute(OWNER_ONLY_DIR_PERMS));
+ // createDirectories applies the attribute only to directories it actually creates, so assert
+ // rather than assume: a fixture that silently reverted to the umask must not go unnoticed again.
+ Assert.assertEquals("the fixture must not leave the store directory at the mercy of the umask",
+ OWNER_ONLY_DIR_PERMS, Files.getPosixFilePermissions(dir));
+ }
+
+ private static void joinOrFail(Thread t, String what) throws InterruptedException {
+ // never a bare join(): a contender that wedges on a lock it should have degraded out of would hang
+ // the suite until the 20-minute surefire timeout, reported as an opaque stall with no failing
+ // assertion. Bounded, then asserted, so the wedge fails as itself.
+ t.join(30_000);
+ Assert.assertFalse(what + " did not finish within 30s [state=" + t.getState() + ']', t.isAlive());
+ }
+
+ private Path lockFile(Path dir, TokenStoreKey key) {
+ return dir.resolve(key.hash() + ".lock");
+ }
+
+ private String readLockStamp(Path lock) {
+ // the owner nonce a live holder stamped into the lock, or null when there is no lock file at all. An
+ // IO error here is a harness fault, so it fails loudly rather than reading as "no lock"
+ try {
+ return Files.exists(lock) ? new String(Files.readAllBytes(lock), StandardCharsets.UTF_8) : null;
+ } catch (IOException e) {
+ throw new AssertionError("could not read the lock stamp: " + lock, e);
+ }
+ }
+
+ private Path storeDir() {
+ // a non-existent subdirectory so the store creates it (and we can assert its permissions)
+ return temp.getRoot().toPath().resolve("oidc-tokens");
+ }
+
+ private Path tokenFile(Path dir, TokenStoreKey key) {
+ return dir.resolve(key.hash() + ".json");
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java
new file mode 100644
index 000000000..6e804ddc2
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java
@@ -0,0 +1,486 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.std.str.StringSink;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * A minimal HTTP/1.1 server for tests that impersonates an OIDC identity provider (and,
+ * when needed, the QuestDB {@code /settings} endpoint). It speaks just enough HTTP to drive
+ * {@link io.questdb.client.cutlass.auth.OidcDeviceAuth}: it reads a request, hands the path
+ * and body to a {@link Handler}, and writes back a {@code Content-Length}-framed response on
+ * a keep-alive connection.
+ */
+public class MockOidcServer implements Closeable {
+ private final Thread acceptThread;
+ private final List connSockets = Collections.synchronizedList(new ArrayList<>());
+ private final List connThreads = Collections.synchronizedList(new ArrayList<>());
+ private final Handler handler;
+ // The FIRST throwable a Handler raised on a daemon connection thread (typically an assertion inside a
+ // handler). handleConnection captures it here rather than letting it die on that thread as a mere
+ // transport drop the client may swallow; close() resurfaces it on the test thread.
+ private final AtomicReference handlerError = new AtomicReference<>();
+ private final List requestAuthHeaders = Collections.synchronizedList(new ArrayList<>());
+ private final ServerSocket serverSocket;
+
+ public MockOidcServer(Handler handler) throws IOException {
+ this.handler = handler;
+ this.serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
+ this.acceptThread = new Thread(this::acceptLoop, "mock-oidc-accept");
+ this.acceptThread.setDaemon(true);
+ this.acceptThread.start();
+ }
+
+ public static MockResponse chunkedJson(int status, String body) {
+ return new MockResponse(status, body, true);
+ }
+
+ public static MockResponse dropConnection() {
+ // close the connection without responding, so the client sees a transport failure (connection
+ // reset / EOF) on this request - used to simulate an unreachable endpoint that is co-located with
+ // a working one on the same mock origin
+ MockResponse response = new MockResponse(0, "", false);
+ response.dropConnection = true;
+ return response;
+ }
+
+ public static MockResponse json(int status, String body) {
+ return new MockResponse(status, body, false);
+ }
+
+ public static MockResponse oversizedJson(long bodyBytes) {
+ // stream a chunked body larger than the client's response-size cap (MAX_RESPONSE_BODY_BYTES), so the
+ // bounded read aborts on the cap instead of letting a hostile or MITM'd server stream an endless body
+ // and wedge the thread. The payload is all whitespace, which the JSON lexer skips, so the byte cap is
+ // what trips - not a parse error, and not the lexer's per-value length limit
+ MockResponse response = new MockResponse(200, "", true);
+ response.oversizedBodyBytes = bodyBytes;
+ return response;
+ }
+
+ public static MockResponse raw(String rawResponse) {
+ // write the supplied bytes verbatim as the whole HTTP response, so a test can craft a malformed
+ // status line (for example a status code carrying control bytes) that the int-typed status factories
+ // cannot express
+ MockResponse response = new MockResponse(0, "", false);
+ response.rawResponse = rawResponse;
+ return response;
+ }
+
+ public static MockResponse dribble() {
+ return dribble(200);
+ }
+
+ /**
+ * A dribbled chunked body under an arbitrary status, so a test can drive the response-body read bound
+ * on the ERROR path (where the status is the verdict and the body is only detail) as well as on the
+ * success path.
+ */
+ public static MockResponse dribble(int status) {
+ MockResponse response = new MockResponse(status, "", true);
+ response.dribble = true;
+ return response;
+ }
+
+ /**
+ * Dribbles the response HEAD - the status line and headers - one byte at a time and never terminates it,
+ * so the client never reaches a complete header block. The body-dribbling {@link #dribble()} above cannot
+ * reach this: a client only starts reading a body once the head has parsed, so the head read is a
+ * separate bound with a separate loop ({@code ResponseHeaders.await}, not {@code Response.recv}).
+ */
+ public static MockResponse dribbleHead() {
+ MockResponse response = new MockResponse(200, "", true);
+ response.dribbleHead = true;
+ return response;
+ }
+
+ public static MockResponse stall() {
+ MockResponse response = new MockResponse(200, "", true);
+ response.stall = true;
+ return response;
+ }
+
+ @Override
+ public void close() throws IOException {
+ // tear the server down deterministically so a test's threads are gone before its assertions (and
+ // assertMemoryLeak's native-memory check) run, instead of lingering as daemon threads that can
+ // perturb a later test: stop accepting, drop every connection (which unblocks a handler reading a
+ // socket), then interrupt and join the accept and connection threads (interrupt wakes a stalled
+ // handler that is sleeping on the response body)
+ serverSocket.close();
+ synchronized (connSockets) {
+ for (Socket s : connSockets) {
+ try {
+ s.close();
+ } catch (IOException ignore) {
+ // already closed
+ }
+ }
+ }
+ interruptAndJoin(acceptThread);
+ synchronized (connThreads) {
+ for (Thread t : connThreads) {
+ interruptAndJoin(t);
+ }
+ }
+ // Resurface a failure a Handler raised on its daemon connection thread (see handleConnection): it is
+ // otherwise visible only as a transport drop the client may swallow, turning a broken assertion into a
+ // green test. Teardown ran first, so nothing leaks; rethrow after it so a test whose body otherwise
+ // passed still fails with the real cause (and one whose body already failed gets it as a suppressed
+ // exception on the primary failure).
+ Throwable handlerFailure = handlerError.get();
+ if (handlerFailure != null) {
+ if (handlerFailure instanceof Error) {
+ throw (Error) handlerFailure;
+ }
+ if (handlerFailure instanceof RuntimeException) {
+ throw (RuntimeException) handlerFailure;
+ }
+ throw new RuntimeException(handlerFailure);
+ }
+ }
+
+ public String httpUrl(String path) {
+ return "http://127.0.0.1:" + port() + path;
+ }
+
+ public int port() {
+ return serverSocket.getLocalPort();
+ }
+
+ public List requestAuthHeaders() {
+ return requestAuthHeaders;
+ }
+
+ private static void interruptAndJoin(Thread t) {
+ t.interrupt();
+ try {
+ t.join(5_000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static String readLine(InputStream in) throws IOException {
+ StringSink sb = new StringSink();
+ boolean any = false;
+ int c;
+ while ((c = in.read()) != -1) {
+ any = true;
+ if (c == '\r') {
+ continue;
+ }
+ if (c == '\n') {
+ return sb.toString();
+ }
+ sb.put((char) c);
+ }
+ return any ? sb.toString() : null;
+ }
+
+ private static Request readRequest(InputStream in) throws IOException {
+ String requestLine = readLine(in);
+ if (requestLine == null || requestLine.isEmpty()) {
+ return null;
+ }
+ String[] parts = requestLine.split(" ");
+ String method = parts[0];
+ String path = parts.length > 1 ? parts[1] : "";
+ int contentLength = 0;
+ String authorization = null;
+ String line;
+ while ((line = readLine(in)) != null && !line.isEmpty()) {
+ int idx = line.indexOf(':');
+ if (idx > 0) {
+ String name = line.substring(0, idx).trim();
+ if ("content-length".equalsIgnoreCase(name)) {
+ contentLength = Integer.parseInt(line.substring(idx + 1).trim());
+ } else if ("authorization".equalsIgnoreCase(name)) {
+ authorization = line.substring(idx + 1).trim();
+ }
+ }
+ }
+ String body = "";
+ if (contentLength > 0) {
+ byte[] buf = new byte[contentLength];
+ int read = 0;
+ while (read < contentLength) {
+ int n = in.read(buf, read, contentLength - read);
+ if (n < 0) {
+ break;
+ }
+ read += n;
+ }
+ body = new String(buf, 0, read, StandardCharsets.UTF_8);
+ }
+ return new Request(method, path, body, authorization);
+ }
+
+ private static String reason(int status) {
+ switch (status) {
+ case 200:
+ return "OK";
+ case 400:
+ return "Bad Request";
+ case 401:
+ return "Unauthorized";
+ case 403:
+ return "Forbidden";
+ case 404:
+ return "Not Found";
+ default:
+ return "Status";
+ }
+ }
+
+ private static void writeChunked(OutputStream out, byte[] body) throws IOException {
+ // split into small chunks so a multi-KB value spans several, exercising the chunked decoder
+ final int chunkSize = 64;
+ for (int off = 0; off < body.length; off += chunkSize) {
+ int len = Math.min(chunkSize, body.length - off);
+ out.write((Integer.toHexString(len) + "\r\n").getBytes(StandardCharsets.US_ASCII));
+ out.write(body, off, len);
+ out.write("\r\n".getBytes(StandardCharsets.US_ASCII));
+ }
+ out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); // terminal chunk
+ }
+
+ private static void writeOversized(OutputStream out, long bodyBytes) throws IOException {
+ // chunked body of the requested size, all whitespace after the opening brace so the JSON lexer keeps
+ // consuming (no per-value limit) until the client trips its response-size cap. The client aborts and
+ // closes the connection mid-stream once the cap is crossed, so tolerate the write failing under us
+ out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
+ final int chunkLen = 64 * 1024;
+ final byte[] chunk = new byte[chunkLen];
+ Arrays.fill(chunk, (byte) ' ');
+ chunk[0] = '{'; // open an object once; the rest is whitespace, an unterminated body the cap cuts short
+ final byte[] crlf = "\r\n".getBytes(StandardCharsets.US_ASCII);
+ try {
+ long remaining = bodyBytes;
+ while (remaining > 0) {
+ final int len = (int) Math.min(chunkLen, remaining);
+ out.write((Integer.toHexString(len) + "\r\n").getBytes(StandardCharsets.US_ASCII));
+ out.write(chunk, 0, len);
+ out.write(crlf);
+ chunk[0] = ' '; // only the first chunk opens the object; the rest is pure whitespace
+ remaining -= len;
+ }
+ out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ } catch (IOException ignore) {
+ // expected: the client aborts on its response-size cap mid-stream and closes the connection
+ }
+ }
+
+ private static void writeResponse(OutputStream out, MockResponse response) throws IOException {
+ if (response.rawResponse != null) {
+ out.write(response.rawResponse.getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ return;
+ }
+ if (response.oversizedBodyBytes > 0) {
+ writeOversized(out, response.oversizedBodyBytes);
+ return;
+ }
+ if (response.stall) {
+ // send chunked headers then block without sending the body, so the client must abort on its
+ // own configured deadline rather than wedging on the HttpClient default timeout
+ out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ try {
+ Thread.sleep(30_000);
+ } catch (InterruptedException ignore) {
+ }
+ return;
+ }
+ if (response.dribbleHead) {
+ // Dribble the response HEAD one byte at a time and never terminate it: no blank line ever
+ // arrives, so the header parser stays incomplete and ResponseHeaders.await keeps looping. Each
+ // read makes progress within its budget, so a client that re-armed a per-read timeout would run
+ // for (bytes x timeout) - long enough that the @Test timeout fires instead - while one bounding
+ // the WHOLE call aborts on its own deadline. Header bytes only, so nothing here can be mistaken
+ // for a body. Stop once the client aborts and closes the socket (the write throws).
+ // Well-formed throughout - a status line followed by endlessly repeated padding headers - so the
+ // client aborts on its deadline rather than on a parse error, which would prove nothing about
+ // the bound. The blank line that would end the head is never sent.
+ final StringBuilder head = new StringBuilder("HTTP/1.1 200 OK\r\n");
+ for (int i = 0; i < 400; i++) {
+ head.append("X-Pad-").append(i).append(": pad\r\n");
+ }
+ final byte[] headBytes = head.toString().getBytes(StandardCharsets.US_ASCII);
+ try {
+ for (byte headByte : headBytes) {
+ out.write(headByte);
+ out.flush();
+ Thread.sleep(50);
+ }
+ } catch (IOException | InterruptedException ignore) {
+ // the client aborted on its whole-read deadline and closed the socket
+ }
+ return;
+ }
+ if (response.dribble) {
+ // send chunked headers, then dribble the chunk-size LINE one hex digit at a time (never the
+ // terminating CRLF), so the client's single recv() keeps looping on the incomplete line while
+ // wall-clock accumulates. This exercises the WHOLE-read timeout bound, not the per-read one: each
+ // recvOrDie makes progress (gets a byte) within its shrinking budget, so a client that only re-armed
+ // a per-read timeout would loop forever, while one bounding the whole read aborts on its deadline.
+ // A modest digit count keeps the accumulated chunk size within a long. Stop once the client aborts
+ // and closes the socket (the write throws). The status is whatever the test asked dribble()
+ // for, so the same dribble drives the success path and the error path.
+ out.write(("HTTP/1.1 " + response.status + " STATUS\r\nContent-Type: application/json\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ try {
+ for (int i = 0; i < 100; i++) {
+ // leading-zero hex digits of a never-terminated chunk-size line: the parsed size stays 0
+ // (so nothing overflows) while the line never completes, keeping recv() looping
+ out.write('0');
+ out.flush();
+ Thread.sleep(100);
+ }
+ } catch (IOException | InterruptedException ignore) {
+ // the client aborted on its whole-read deadline and closed the socket
+ }
+ return;
+ }
+ byte[] bodyBytes = response.body.getBytes(StandardCharsets.UTF_8);
+ StringSink head = new StringSink();
+ head.put("HTTP/1.1 ").put(response.status).put(' ').put(reason(response.status)).put("\r\n");
+ head.put("Content-Type: application/json\r\n");
+ if (response.chunked) {
+ head.put("Transfer-Encoding: chunked\r\n");
+ head.put("\r\n");
+ out.write(head.toString().getBytes(StandardCharsets.US_ASCII));
+ writeChunked(out, bodyBytes);
+ } else {
+ head.put("Content-Length: ").put(bodyBytes.length).put("\r\n");
+ head.put("\r\n");
+ out.write(head.toString().getBytes(StandardCharsets.US_ASCII));
+ out.write(bodyBytes);
+ }
+ out.flush();
+ }
+
+ private void acceptLoop() {
+ while (!serverSocket.isClosed()) {
+ try {
+ Socket socket = serverSocket.accept();
+ connSockets.add(socket);
+ Thread connThread = new Thread(() -> handleConnection(socket), "mock-oidc-conn");
+ connThread.setDaemon(true);
+ connThreads.add(connThread);
+ connThread.start();
+ } catch (IOException e) {
+ // server socket closed, stop accepting
+ return;
+ }
+ }
+ }
+
+ private void handleConnection(Socket socket) {
+ try (InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) {
+ Request request;
+ while ((request = readRequest(in)) != null) {
+ requestAuthHeaders.add(request.authorization);
+ MockResponse response;
+ try {
+ response = handler.handle(request.method, request.path, request.body);
+ } catch (Throwable t) {
+ // The Handler runs on this daemon connection thread, where an uncaught throwable - an
+ // assertion inside a handler, most often - is otherwise swallowed: the client sees only a
+ // transport drop it may tolerate (a silent false pass) or retry into an opaque @Test
+ // timeout. Capture the FIRST such failure so close() can resurface it on the test thread,
+ // then drop the connection exactly as a return would, leaving client-visible behaviour
+ // unchanged.
+ handlerError.compareAndSet(null, t);
+ return;
+ }
+ if (response.dropConnection) {
+ // returning closes the socket (try-with-resources on its streams), so the client's
+ // in-flight read fails with a transport error
+ return;
+ }
+ writeResponse(out, response);
+ }
+ } catch (SocketException e) {
+ // client closed the connection, expected
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @FunctionalInterface
+ public interface Handler {
+ MockResponse handle(String method, String path, String body);
+ }
+
+ public static class MockResponse {
+ final String body;
+ final boolean chunked;
+ final int status;
+ boolean dribble;
+ boolean dribbleHead;
+ boolean dropConnection;
+ long oversizedBodyBytes;
+ String rawResponse;
+ boolean stall;
+
+ MockResponse(int status, String body, boolean chunked) {
+ this.status = status;
+ this.body = body;
+ this.chunked = chunked;
+ }
+ }
+
+ public static class Request {
+ final String authorization;
+ final String body;
+ final String method;
+ final String path;
+
+ Request(String method, String path, String body, String authorization) {
+ this.method = method;
+ this.path = path;
+ this.body = body;
+ this.authorization = authorization;
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServerTest.java
new file mode 100644
index 000000000..f2b29c777
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServerTest.java
@@ -0,0 +1,117 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.cutlass.auth.OidcAuthException;
+import io.questdb.client.cutlass.auth.OidcDeviceAuth;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Self-tests for {@link MockOidcServer}, the harness the OIDC suites assert through.
+ *
+ * Its load-bearing property is not that it serves JSON - every OIDC test would fail loudly if it did not -
+ * but that a {@link MockOidcServer.Handler} failure REACHES THE TEST THREAD. A handler runs on a daemon
+ * connection thread, where an uncaught throwable is otherwise invisible: the client sees a dropped
+ * connection, which most of these tests tolerate as one more transport failure, so a broken assertion inside
+ * a handler reads as a passing test. {@code handleConnection} captures the first such throwable and
+ * {@code close()} rethrows it, and that path had no test of its own - every suite depended on it while
+ * nothing proved it worked.
+ */
+public class MockOidcServerTest {
+
+ private static final String DEVICE_PATH = "/device";
+
+ @Test(timeout = 30_000)
+ public void testAHandlerAssertionFailureReachesTheTestThread() throws Exception {
+ assertMemoryLeak(() -> {
+ // The shape that matters: an assertion inside a handler. Without the capture-and-rethrow, the
+ // client below sees a dropped connection, turns it into an OidcAuthException the test could
+ // easily be written to expect, and the broken assertion is never heard from again.
+ AssertionError thrownByHandler = new AssertionError("the handler asserted something and it failed");
+ boolean rethrown = false;
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> {
+ throw thrownByHandler;
+ })) {
+ try (OidcDeviceAuth auth = newAuth(server)) {
+ auth.signIn();
+ Assert.fail("the handler threw, so the client cannot have completed a sign-in");
+ } catch (OidcAuthException expected) {
+ // the client's view of a handler failure: the connection simply dropped
+ }
+ } catch (AssertionError e) {
+ rethrown = true;
+ Assert.assertSame("close() must resurface the handler's OWN throwable, not a copy",
+ thrownByHandler, e);
+ }
+ Assert.assertTrue("close() must resurface a handler failure on the test thread", rethrown);
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testAHealthyRunClosesQuietlyAndRecordsItsRequests() throws Exception {
+ assertMemoryLeak(() -> {
+ // The control for the test above: without it, a close() that rethrew unconditionally - or a
+ // server that recorded a phantom failure - would look exactly like a working propagation path.
+ AtomicInteger deviceCalls = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, "{\"device_code\":\"DEV-CODE\",\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\",\"expires_in\":300,"
+ + "\"interval\":1}");
+ }
+ return MockOidcServer.json(200, "{\"token_type\":\"Bearer\",\"expires_in\":3600,"
+ + "\"access_token\":\"ACCESS-1\"}");
+ })) {
+ try (OidcDeviceAuth auth = newAuth(server)) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ }
+ Assert.assertEquals(1, deviceCalls.get());
+ // requestAuthHeaders() records one entry per request READ, header or not - the OIDC endpoints
+ // are unauthenticated, so these are nulls, and the count is what QwpQueryClientTokenProviderTest
+ // asserts on
+ List headers = server.requestAuthHeaders();
+ Assert.assertTrue("every request read must be recorded: " + headers, headers.size() >= 2);
+ } // a clean close: no throwable to resurface, so this must not throw
+ });
+ }
+
+ private static OidcDeviceAuth newAuth(MockOidcServer server) {
+ return OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl("/token"))
+ .allowInsecureTransport(true)
+ .prompt(challenge -> {
+ })
+ .build();
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java
new file mode 100644
index 000000000..4191b83fa
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java
@@ -0,0 +1,1811 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.cutlass.auth.FileTokenStore;
+import io.questdb.client.cutlass.auth.OidcAuthException;
+import io.questdb.client.cutlass.auth.OidcDeviceAuth;
+import io.questdb.client.cutlass.auth.PersistedToken;
+import io.questdb.client.cutlass.auth.TokenStore;
+import io.questdb.client.cutlass.auth.TokenStoreKey;
+import io.questdb.client.test.tools.NoBrowserLaunch;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+public class OidcDeviceAuthPersistenceTest {
+ private static final String DEVICE_PATH = "/device";
+ private static final String TOKEN_PATH = "/token";
+
+ // a restored sign-in here reaches the device-code prompt; see NoBrowserLaunch for why this is a rule
+ @ClassRule
+ public static final NoBrowserLaunch NO_BROWSER = new NoBrowserLaunch();
+
+ @Rule
+ public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test(timeout = 30_000)
+ public void testAdoptTrustsStoredIssuedTtlNotRemainingSpan() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-FRESH", "REFRESH-FRESH", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a token issued for 5m (stored ttl) loaded with only ~100s of life left. adopt() must set
+ // tokenTtlMillis from the stored ISSUED lifetime (5m), NOT the remaining span (~100s): the
+ // remaining-span form shrinks the effectiveSkewMillis basis as a token ages and collapses the
+ // clock-skew margin near expiry (guarded by testAdoptedTokenNearExpiryStillRefreshesOnFlushPath).
+ // A tampered ttl can only shrink the skew (never inflate it past CLOCK_SKEW_MILLIS) and the server
+ // still enforces the real expiry, so trusting the stored value is no less safe.
+ long now = System.currentTimeMillis();
+ fake.loadReturns = new PersistedToken("ACCESS-1", null, "REFRESH-1", now + 100_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("the still-valid persisted token is served", "ACCESS-1", auth.signIn());
+ long ttl = readPrivateLong(auth, "tokenTtlMillis");
+ Assert.assertEquals("tokenTtlMillis must be the stored 5m issued lifetime, not the ~100s remaining span",
+ 300_000L, ttl);
+ }
+ Assert.assertEquals("no device flow for a valid persisted token", 0, device.get());
+ Assert.assertEquals("no refresh for a valid persisted token", 0, token.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testAdoptedTokenNearExpiryStillRefreshesOnFlushPath() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-REFRESHED", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a token issued for 5m (stored ttl) but loaded with only ~20s of life left. The 30s clock-skew
+ // margin exceeds the remaining life, so getToken() (the flush path) must silently refresh rather
+ // than serve a token that would expire mid-request. Deriving the skew basis from the remaining
+ // span (the pre-fix bug) collapses the margin to ~10s and serves the near-expired token instead.
+ long now = System.currentTimeMillis();
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-1", now + 20_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("a token inside the clock-skew margin must be refreshed, not served",
+ "ACCESS-REFRESHED", auth.getToken());
+ }
+ Assert.assertEquals("device flow must not run; a silent refresh suffices", 0, device.get());
+ Assert.assertTrue("the token endpoint must be hit for the refresh", token.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testCancelledStoreLockWaitDoesNotArmTheSharedRefreshBackOff() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // an expired access token with a live refresh token: getToken() must want a silent refresh
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-1",
+ System.currentTimeMillis() - 1_000, 300_000);
+ fake.cancelWaitInsteadOfRunning = true;
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ try {
+ auth.getToken();
+ Assert.fail("getToken must report the cancellation rather than a refresh failure");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("interrupted"));
+ }
+ // refreshFailedAtMillis is INSTANCE state shared by every producer holding this
+ // OidcDeviceAuth. Arming it here would fail all of them for
+ // MIN_REFRESH_RETRY_INTERVAL_MILLIS over a credential that is fine and an identity
+ // provider that was never contacted - the cancelled wait ran no refresh at all.
+ Assert.assertEquals("a cancelled lock wait must not arm the shared refresh back-off",
+ 0L, readPrivateLong(auth, "refreshFailedAtMillis"));
+ Assert.assertEquals("the token endpoint must not have been called", 0, token.get());
+ } finally {
+ // the store set the flag on this thread by design; do not leak it into later tests
+ Thread.interrupted();
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testCancelledStoreLockWaitDoesNotStartTheDeviceFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-1",
+ System.currentTimeMillis() - 1_000, 300_000);
+ fake.cancelWaitInsteadOfRunning = true;
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ try {
+ auth.signIn();
+ Assert.fail("signIn must decline once a cancellation abandoned the store lock wait");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("interrupted"));
+ }
+ // The device flow is the expensive wrong answer: it launches a browser and then polls
+ // to the device-code lifetime on Os.sleep, which ignores interrupts - so a caller that
+ // cancelled this thread cannot get it back, and shutdown does not complete. A plain
+ // false from inLock reads as "the refresh failed", which is exactly what sends signIn()
+ // here.
+ Assert.assertEquals("a cancelled lock wait must not start the interactive device flow",
+ 0, device.get());
+ Assert.assertEquals("and must not reach the token endpoint either", 0, token.get());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuildRejectsFileTokenStoreWithTooSmallStaleWindow() throws Exception {
+ assertMemoryLeak(() -> {
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(200, "{}"))) {
+ Path dir = storeDir();
+ // a lock-staleness window below LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE (4) x httpTimeoutMillis would let a
+ // peer judge a live holder's lock stale and steal it mid-refresh, reopening the rotating-refresh-
+ // token race the lock prevents; build() must reject the combination rather than ship the race
+ try {
+ baseBuilder(server)
+ .httpTimeoutMillis(30_000)
+ .tokenStore(new FileTokenStore(dir, 3_000, 119_999))
+ .build();
+ Assert.fail("a lockStaleMillis below 4x httpTimeoutMillis must be rejected");
+ } catch (OidcAuthException expected) {
+ Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("lockStaleMillis"));
+ }
+ // exactly 4x httpTimeoutMillis is the boundary and builds
+ try (OidcDeviceAuth ignored = baseBuilder(server)
+ .httpTimeoutMillis(30_000)
+ .tokenStore(new FileTokenStore(dir, 3_000, 120_000))
+ .build()) {
+ // building at the boundary succeeds
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderRejectsHttpTimeoutAboveCap() throws Exception {
+ assertMemoryLeak(() -> {
+ // the HTTP timeout is capped (120s): a token-endpoint round-trip never needs longer, and bounding
+ // it keeps a refresh held under the FileTokenStore cross-process lock safely shorter than that
+ // store's staleness window, so a slow refresh's live lock is not stolen by a peer. Above the cap is
+ // rejected; the boundary value builds.
+ try {
+ OidcDeviceAuth.builder().httpTimeoutMillis(120_001);
+ Assert.fail("a httpTimeoutMillis above the cap must be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpTimeoutMillis"));
+ }
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint("https://idp.example/device")
+ .tokenEndpoint("https://idp.example/token")
+ .httpTimeoutMillis(120_000)
+ .build()) {
+ // building at the cap boundary succeeds
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testClearCacheDeletesPersistedEntry() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ Path file = dir.resolve(keyFor(server).hash() + ".json");
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ auth.signIn();
+ Assert.assertTrue(Files.exists(file));
+
+ auth.clearCache();
+ Assert.assertFalse("clearCache must remove the persisted entry", Files.exists(file));
+
+ int before = device.get();
+ auth.signIn();
+ Assert.assertTrue("a cleared cache must re-run the device flow", device.get() > before);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testClearCacheDoesNotReloadStaleEntry() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-NEW", null, "REFRESH-NEW", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // load() keeps returning a valid entry even after clear() (loadReturns is not cleared); this
+ // proves clearCache() does not re-read the store - it relies on storeLoadAttempted, not on the
+ // store having actually forgotten the entry - so a fresh device flow runs rather than re-adopting
+ fake.loadReturns = new PersistedToken("ACCESS-OLD", null, "REFRESH-OLD",
+ System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("ACCESS-OLD", auth.signIn());
+ int loadsAfterFirst = fake.loads.get();
+
+ auth.clearCache();
+ Assert.assertEquals("clearCache must clear the persisted entry exactly once", 1, fake.clears.get());
+
+ // even though load() would still hand back ACCESS-OLD, clearCache must not let it be reloaded
+ Assert.assertEquals("ACCESS-NEW", auth.signIn());
+ Assert.assertEquals("clearCache must not trigger a re-read of the store",
+ loadsAfterFirst, fake.loads.get());
+ Assert.assertEquals("a cleared cache must re-run the device flow", 1, device.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDeviceGrantWithoutRefreshTokenClearsPreviousUsersRefreshToken() throws Exception {
+ assertMemoryLeak(() -> {
+ // Cross-account confusion. storeTokens() keeps the current refresh token whenever a response
+ // omits one -- correct for a REFRESH response, which RFC 6749 6 lets omit it, but wrong for a
+ // fresh device grant. A device grant is a NEW authorization and may be a DIFFERENT human: if it
+ // returns no refresh token, the previous user's must not survive it, or the next silent refresh
+ // signs back in as them with no interaction and no signal.
+ //
+ // A: persisted, expired access token plus a live refresh token.
+ // B: signs in interactively after A's refresh hits a transient IdP failure, and B's grant carries
+ // no refresh token of its own.
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger refreshCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // A's refresh token is live, not revoked -- the first attempt just lands on a 503. That is
+ // what makes this a confusion bug rather than a dead credential: the token still works, so
+ // any later use of it silently resumes A's session.
+ if (refreshCalls.incrementAndGet() == 1) {
+ return MockOidcServer.json(503, "{}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-A2", null, "REFRESH-A", 3600));
+ }
+ // B's device grant: a served token and deliberately NO refresh token. expires_in=1 so B's
+ // token goes stale inside the test without stubbing the clock.
+ return MockOidcServer.json(200, tokenJson("ACCESS-B", null, null, 1));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.stored = new PersistedToken("ACCESS-A", null, "REFRESH-A", System.currentTimeMillis() - 1, 3600_000);
+
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("A's refresh fails, so B signs in interactively",
+ "ACCESS-B", auth.signIn());
+ Assert.assertEquals("the device flow must have run for B", 1, device.get());
+
+ // B's token is issued for 1s and effectiveSkewMillis caps the skew at half that, so it
+ // reads as stale ~500ms in. Wait past that, then ask for a token the way the sender does.
+ Thread.sleep(1_000L);
+ try {
+ String served = auth.getToken();
+ Assert.fail("B's expired token must not be refreshed with A's retained refresh token; "
+ + "getToken() served [" + served + "] after " + refreshCalls.get() + " refresh calls");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue("expected a prompt to sign in again, got: " + e.getMessage(),
+ e.getMessage().contains("could not be refreshed without an interactive sign-in"));
+ }
+ Assert.assertEquals("no refresh may be attempted once B's grant carried no refresh token",
+ 1, refreshCalls.get());
+ }
+
+ // Persistence half: A's refresh token must not outlive B's sign-in on disk either, or the next
+ // process start adopts it and resumes as A.
+ Assert.assertNotNull("B's grant must have been persisted over A's entry", fake.stored);
+ Assert.assertNull("A's refresh token must not survive in the store: " + fake.stored.getRefreshToken(),
+ fake.stored.getRefreshToken());
+
+ // Restart over the same store.
+ int refreshesBeforeRestart = refreshCalls.get();
+ try (OidcDeviceAuth restarted = baseBuilder(server).tokenStore(fake).build()) {
+ try {
+ String served = restarted.getToken();
+ Assert.fail("a restart must not resume A's session; getToken() served [" + served + "]");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue("expected a sign-in prompt after restart, got: " + e.getMessage(),
+ e.getMessage().contains("could not be refreshed without an interactive sign-in"));
+ }
+ }
+ Assert.assertEquals("a restart must not refresh with A's token either",
+ refreshesBeforeRestart, refreshCalls.get());
+ }
+ });
+ }
+
+ @Test
+ public void testDefaultInLockRunsTheAction() {
+ // TokenStore.inLock has a default that simply runs the action (no cross-process coordination). It is a
+ // public extension point users implement, so a store that does NOT override inLock must still run its
+ // critical section and return the action's result. Both in-tree stores override inLock, so this pins the
+ // default directly; a regression to, say, "return false" without running the action would fail here.
+ TokenStore store = new TokenStore() {
+ @Override
+ public void clear(TokenStoreKey key) {
+ }
+
+ @Override
+ public PersistedToken load(TokenStoreKey key) {
+ return null;
+ }
+
+ @Override
+ public void save(TokenStoreKey key, PersistedToken token) {
+ }
+ };
+ AtomicBoolean ran = new AtomicBoolean();
+ boolean result = store.inLock(null, () -> {
+ ran.set(true);
+ return true;
+ });
+ Assert.assertTrue("the default inLock must run the action", ran.get());
+ Assert.assertTrue("the default inLock must return the action's result", result);
+ }
+
+ @Test
+ public void testEntryWithNoTokenOfEitherKindIsNotAdopted() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicBoolean sawPlantedRefreshToken = new AtomicBoolean();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ if (body != null && body.contains("REFRESH-PLANTED")) {
+ sawPlantedRefreshToken.set(true);
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // The cheapest credential swap there is: an entry carrying ONLY a refresh token. Every entry
+ // this client writes carries at least one token kind, so this shape came from somewhere else -
+ // an attacker who can WRITE the store directory, without ever reading our 0600 file. Adopted,
+ // the next silent refresh would present THEIR refresh token and the client would resume as
+ // them, with no prompt and nothing in any log recording the change of identity.
+ fake.loadReturns = new PersistedToken(null, null, "REFRESH-PLANTED",
+ System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("ACCESS-FRESH", auth.signIn());
+ }
+ Assert.assertFalse("the planted refresh token must never reach the token endpoint",
+ sawPlantedRefreshToken.get());
+ Assert.assertTrue("a rejected entry must fall back to the device flow, not to a silent refresh",
+ device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenBacksOffAfterAFailedRefreshInsteadOfFloodingTheIdp() throws Exception {
+ assertMemoryLeak(() -> {
+ // getToken() runs once per ILP flush and once per (re)connect, and a producer retrying its rows
+ // calls it in a tight loop. Without a back-off a revoked refresh token cost a full
+ // token-endpoint round trip on EVERY call: a sustained request flood at the provider - enough to
+ // trip its rate limits and lengthen the outage being retried - and a producer blocked for each
+ // round trip, up to the OS TCP-connect timeout against a black-holed endpoint.
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ tokenCalls.incrementAndGet();
+ // the shape of a revoked refresh token
+ return MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-REVOKED",
+ System.currentTimeMillis() - 1, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ for (int i = 0; i < 25; i++) {
+ try {
+ auth.getToken();
+ Assert.fail("a revoked refresh token must not yield a usable token");
+ } catch (OidcAuthException expected) {
+ // every call still reports the failure - only the network attempt is rate-limited
+ }
+ }
+ }
+ Assert.assertEquals("25 getToken() calls must not mean 25 token-endpoint round trips",
+ 1, tokenCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testGetTokenBackOffExpiresWhileAProducerKeepsCalling() throws Exception {
+ assertMemoryLeak(() -> {
+ // The companion of the throttle test above, and the half that cannot be checked by a tight
+ // loop: the back-off must EXPIRE on its own while the caller keeps calling. Arming the latch
+ // on a call the back-off itself skipped slides its window forward by one call every time, so
+ // it never elapses for any caller returning faster than the retry interval - and getToken()
+ // runs once per ILP flush, at a default auto-flush interval of one second. One transient
+ // identity-provider failure then wedges the sender for the life of the process.
+ //
+ // Drives real wall clock because that is the only thing that distinguishes the two shapes:
+ // both serve the same token, throttle to one round trip inside the window, and differ only in
+ // whether a later call is ever allowed through.
+ final long retryIntervalMillis = 5_000L; // OidcDeviceAuth.MIN_REFRESH_RETRY_INTERVAL_MILLIS
+ AtomicBoolean healthy = new AtomicBoolean();
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ tokenCalls.incrementAndGet();
+ if (!healthy.get()) {
+ // a transient outage, not a revoked grant: tryRefresh reports failure and leaves the
+ // cached refresh token intact, so the next attempt would succeed
+ return MockOidcServer.json(503, "{\"error\":\"temporarily_unavailable\"}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-RECOVERED", null, "REFRESH-NEW", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-OK",
+ System.currentTimeMillis() - 1, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ try {
+ auth.getToken();
+ Assert.fail("the first call must attempt the refresh and surface its failure");
+ } catch (OidcAuthException expected) {
+ }
+ Assert.assertEquals("the first call must have reached the token endpoint",
+ 1, tokenCalls.get());
+
+ // the provider recovers, and the caller keeps polling well inside the window, exactly
+ // as a flushing producer does
+ healthy.set(true);
+ String token = null;
+ final long deadlineNanos = System.nanoTime() + 4 * retryIntervalMillis * 1_000_000L;
+ while (token == null && System.nanoTime() - deadlineNanos < 0) {
+ Thread.sleep(50);
+ try {
+ token = auth.getToken();
+ } catch (OidcAuthException stillBackedOff) {
+ }
+ }
+ Assert.assertEquals("the back-off must expire on its own; the identity provider "
+ + "recovered and getToken() never retried it",
+ "ACCESS-RECOVERED", token);
+ Assert.assertEquals("exactly one retry, once the window had elapsed",
+ 2, tokenCalls.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSignInDeclinesOnAnInterruptCarryingThread() throws Exception {
+ assertMemoryLeak(() -> {
+ // The interrupt guard used to live only inside FileTokenStore.inLock, so signIn()'s behaviour
+ // depended on whether a store was configured - and was wrong in OPPOSITE directions either way.
+ //
+ // with a FileTokenStore: inLock declined the carried interrupt by returning false, signIn()
+ // read that as "the refresh failed" and started the DEVICE FLOW - a browser prompt and a poll
+ // loop Os.sleep cannot be interrupted out of, so the cancelled caller was parked for up to the
+ // device-code lifetime, having skipped a refresh it could have completed;
+ //
+ // with no store: tryRefreshCoordinated() went straight to tryRefresh() and POSTed to the token
+ // endpoint on that same cancelled thread.
+ //
+ // Driven twice in one test on purpose: the defect was the ASYMMETRY, so the two configurations
+ // agreeing is the property worth pinning. Both halves reach signIn() holding a usable refresh
+ // token and an expired access token, so each has real network work to decline rather than a
+ // cache hit to serve.
+ for (boolean withStore : new boolean[]{true, false}) {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ OidcDeviceAuth.Builder builder = baseBuilder(server);
+ if (withStore) {
+ // a REAL FileTokenStore, not a double: its inLock is what declines a carried
+ // interrupt, and that decline is the half of the asymmetry that ended in a prompt
+ Path dir = storeDir();
+ new FileTokenStore(dir).save(keyFor(server),
+ new PersistedToken("ACCESS-STALE", null, "REFRESH-OK",
+ System.currentTimeMillis() - 1, 300_000));
+ builder.tokenStore(new FileTokenStore(dir));
+ }
+ try (OidcDeviceAuth auth = builder.build()) {
+ if (!withStore) {
+ // nothing on disk to restore from, so earn a refresh token the ordinary way
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ OidcDeviceAuthTest.expireCachedToken(auth);
+ }
+ final int deviceBefore = device.get();
+ final int tokenBefore = token.get();
+
+ Thread.currentThread().interrupt();
+ try {
+ String served = auth.signIn();
+ Assert.fail("a cancelled caller must not be signed in [withStore=" + withStore
+ + ", served=" + served + ", deviceFlows=" + (device.get() - deviceBefore)
+ + ", tokenCalls=" + (token.get() - tokenBefore) + "]");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue("withStore=" + withStore + ": " + e.getMessage(),
+ e.getMessage().contains("interrupted"));
+ } finally {
+ Assert.assertTrue("the caller's cancellation signal must survive signIn() "
+ + "[withStore=" + withStore + "]", Thread.interrupted());
+ }
+ Assert.assertEquals("no device flow may be started on a cancelled thread "
+ + "[withStore=" + withStore + "]", deviceBefore, device.get());
+ Assert.assertEquals("no token-endpoint round trip may be made on a cancelled thread "
+ + "[withStore=" + withStore + "]", tokenBefore, token.get());
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSignInClearsTheRefreshBackOff() throws Exception {
+ assertMemoryLeak(() -> {
+ // The back-off must never strand a caller: signIn() is the explicit action a user takes to
+ // recover, so it re-attempts immediately and falls through to the device flow.
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ if (body != null && body.contains("REFRESH-REVOKED")) {
+ return MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-NEW", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-REVOKED",
+ System.currentTimeMillis() - 1, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ try {
+ auth.getToken();
+ Assert.fail("expected the revoked refresh to fail");
+ } catch (OidcAuthException expected) {
+ // now latched
+ }
+ Assert.assertEquals("ACCESS-FRESH", auth.signIn());
+ Assert.assertTrue("signIn() must not be held off by the back-off", device.get() >= 1);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenDeclinesTheRefreshOnAnInterruptCarryingThreadWithoutLatchingTheBackOff() throws Exception {
+ assertMemoryLeak(() -> {
+ // A cancelled caller must not drive a network round trip, must keep its cancellation signal, must
+ // be told what actually happened, and must not suppress the next five seconds of legitimate
+ // refreshes for every other thread sharing this instance.
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ tokenCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-REFRESHED", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-1",
+ System.currentTimeMillis() - 1, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Thread.currentThread().interrupt();
+ final boolean flagSurvived;
+ final String message;
+ try {
+ auth.getToken();
+ Assert.fail("a cancelled caller must not get a token out of a network refresh");
+ return;
+ } catch (OidcAuthException e) {
+ message = e.getMessage();
+ flagSurvived = Thread.currentThread().isInterrupted();
+ } finally {
+ Thread.interrupted(); // do not leak the flag into the next test
+ }
+
+ Assert.assertEquals("no refresh may be attempted on a cancelled thread", 0, tokenCalls.get());
+ Assert.assertTrue("the message must name the interrupt, not blame the credential: " + message,
+ message.contains("interrupted"));
+ Assert.assertFalse("it must not send the user to re-authenticate: " + message,
+ message.contains("call signIn()"));
+ Assert.assertTrue("the caller's cancellation signal must survive getToken()", flagSurvived);
+
+ // and the decline must not have latched the back-off: a clean caller refreshes at once
+ Assert.assertEquals("ACCESS-REFRESHED", auth.getToken());
+ Assert.assertEquals(1, tokenCalls.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenDeclinesTheRefreshOnAnInterruptCarryingThreadWithNoTokenStore() throws Exception {
+ assertMemoryLeak(() -> {
+ // The only interrupt guard used to live inside FileTokenStore.inLock, so with NO store configured
+ // a cancelled thread POSTed to the token endpoint regardless. The guard is in getToken() now, so
+ // both shapes behave the same.
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ tokenCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = baseBuilder(server).build()) {
+ Assert.assertEquals("ACCESS-1", auth.signIn()); // one device grant, one token call
+ Assert.assertEquals(1, tokenCalls.get());
+ OidcDeviceAuthTest.expireCachedToken(auth);
+
+ Thread.currentThread().interrupt();
+ try {
+ auth.getToken();
+ Assert.fail("a cancelled caller must not drive a refresh even with no token store");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("interrupted"));
+ } finally {
+ Thread.interrupted();
+ }
+ Assert.assertEquals("no refresh may be attempted on a cancelled thread", 1, tokenCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenAsFirstCallAfterRestore() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ new FileTokenStore(dir).save(keyFor(server),
+ new PersistedToken("ACCESS-1", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000));
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ // getToken() without a prior signIn(): a restored process can flush immediately
+ Assert.assertEquals("ACCESS-1", auth.getToken());
+ }
+ Assert.assertEquals(0, device.get());
+ Assert.assertEquals(0, token.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenDegradesWhenStoreLockHeld() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ // a small acquire budget so the test does not wait the 3s default; a large staleness window so
+ // the pre-created lock is treated as a live peer's and not stolen
+ new FileTokenStore(dir, 200, 600_000).save(keyFor(server),
+ new PersistedToken("OLD-ACCESS", null, "REFRESH-1", System.currentTimeMillis() - 60_000, 300_000));
+ // a peer holds the per-identity lock: getToken() must wait out only its short acquire budget,
+ // then degrade to a lock-free refresh rather than stall the flush path or fail
+ final int acquireBudgetMillis = 200;
+ Path lock = dir.resolve(keyFor(server).hash() + ".lock");
+ Files.createFile(lock);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(
+ new FileTokenStore(dir, acquireBudgetMillis, 600_000)).build()) {
+ long start = System.currentTimeMillis();
+ Assert.assertEquals("ACCESS-2", auth.getToken());
+ long elapsed = System.currentTimeMillis() - start;
+ // Bounded on BOTH sides, because one side alone cannot tell a degrade from an acquire.
+ // Below: the whole acquire budget must have been spent polling for a lock this call never
+ // gets - a run that skipped the wait (or took the lock) returns in single-digit millis.
+ // The peer's lock is empty, so only the 5s EMPTY_LOCK_STEAL_GRACE_MILLIS governs a steal,
+ // and a 200ms budget cannot reach it: the wait is deterministic, not racy.
+ Assert.assertTrue("getToken must wait out the acquire budget before degrading, was " + elapsed,
+ elapsed >= acquireBudgetMillis);
+ // Above: budget + one loopback refresh, with a wide margin. The old bound was 10s, which
+ // a regression to the 3s DEFAULT acquire budget would have sailed through.
+ Assert.assertTrue("getToken must degrade promptly, not stall, was " + elapsed, elapsed < 2_000);
+ }
+ // the definitive degrade-vs-acquire evidence: had getToken() acquired (or stolen) the lock, it
+ // would have deleted the file on release
+ Assert.assertTrue("the peer's lock must be left exactly where it was", Files.exists(lock));
+ Assert.assertEquals("device flow must not run; getToken degrades to a lock-free refresh", 0, device.get());
+ Assert.assertTrue("the refresh must hit the token endpoint", token.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNoStorePersistsNothing() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = baseBuilder(server).build()) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ Assert.assertEquals("ACCESS-1", auth.getToken());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNonRotatingRefreshDoesNotRewrite() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // a non-rotating provider returns no new refresh token
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, null, 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.stored = new PersistedToken("OLD", null, "REFRESH-1", System.currentTimeMillis() - 60_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ }
+ Assert.assertEquals("an unchanged refresh token must not rewrite the file", 0, fake.saves.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshUnderLockAdoptsPeerTokenAndSkipsNetwork() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // our own (expired) entry: adopted on load, leaving us in sync with what we last persisted
+ fake.stored = new PersistedToken("OLD-ACCESS", null, "REFRESH-1", System.currentTimeMillis() - 60_000, 300_000);
+ // a peer refreshes and writes a fresh, still-valid entry while we hold the cross-process lock
+ fake.peerInstallsOnLock = new PersistedToken("PEER-ACCESS", null, "REFRESH-2", System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("must adopt the peer's fresh token from inside the lock", "PEER-ACCESS", auth.signIn());
+ }
+ Assert.assertEquals("a peer's still-valid token must be served without a token-endpoint call", 0, token.get());
+ Assert.assertEquals("device flow must not run", 0, device.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshUnderLockKeepsLiveRefreshTokenWhenPeerEntryOmitsIt() throws Exception {
+ // regression: a peer (or cross-language client, or a tampered file) persists a valid-but-expired served
+ // token with NO refresh_token while we hold a live refresh token in memory. The coordinated re-read must
+ // keep our refresh token, not null it - nulling it made tryRefresh() urlEncode(null) and throw an
+ // uncaught NPE that aborted getToken()/signIn() instead of degrading. With the fix REFRESH-1 is kept and
+ // the refresh succeeds.
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (path.startsWith(DEVICE_PATH)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ // the token endpoint honours the kept refresh token and returns a fresh access token
+ return MockOidcServer.json(200, tokenJson("REFRESHED-ACCESS", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ long now = System.currentTimeMillis();
+ // our own entry, adopted on load: an expired served token carrying REFRESH-1
+ fake.stored = new PersistedToken("OLD-ACCESS", null, "REFRESH-1", now - 60_000, 300_000);
+ // a peer overwrites the file with a valid-but-expired served token and NO refresh_token (the
+ // frozen on-disk format permits omitting it) while we hold the cross-process lock
+ fake.peerInstallsOnLock = new PersistedToken("PEER-ACCESS", null, null, now - 60_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("the live refresh token must be kept and used, not nulled into an NPE",
+ "REFRESHED-ACCESS", auth.getToken());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshUnderLockResavesKeptRefreshTokenWhenPeerEntryOmitsIt() throws Exception {
+ // the other half of the NPE fix: when the coordinated re-read adopts a peer entry that omits the refresh
+ // token, adopt() keeps the live refresh token AND records that the file carried none
+ // (lastPersistedRefreshToken=null). The refresh that follows must therefore RE-SAVE the kept token, so a
+ // restart still finds it on disk. If adopt() instead marked the kept token as already-persisted, the save
+ // would be skipped and the refresh token would silently vanish from disk, forcing a needless re-prompt.
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (path.startsWith(DEVICE_PATH)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ // non-rotating refresh: the same REFRESH-1 comes back, so ONLY the null-vs-REFRESH-1 lastPersisted
+ // bookkeeping (not a token change) decides whether persistIfRotated re-saves
+ return MockOidcServer.json(200, tokenJson("REFRESHED-ACCESS", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ long now = System.currentTimeMillis();
+ // our own entry, adopted on load: an expired served token carrying REFRESH-1
+ fake.stored = new PersistedToken("OLD-ACCESS", null, "REFRESH-1", now - 60_000, 300_000);
+ // a peer overwrites the file with an expired served token and NO refresh_token while we hold the
+ // lock; the re-read adopts it, keeps REFRESH-1, and records that the file carried no refresh token
+ fake.peerInstallsOnLock = new PersistedToken("PEER-ACCESS", null, null, now - 60_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("REFRESHED-ACCESS", auth.getToken());
+ }
+ Assert.assertTrue("the kept refresh token must be re-saved (the file carried none), not skipped as already-persisted",
+ fake.saves.get() >= 1);
+ Assert.assertNotNull("the re-saved entry must exist", fake.stored);
+ Assert.assertEquals("the re-saved entry must carry the kept refresh token", "REFRESH-1", fake.stored.getRefreshToken());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRestartRefreshesExpiredTokenSkippingDeviceFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ // seed an already-expired access token plus a valid refresh token, as if persisted before a restart
+ new FileTokenStore(dir).save(keyFor(server),
+ new PersistedToken("OLD-ACCESS", null, "REFRESH-1", System.currentTimeMillis() - 60_000, 300_000));
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ }
+ Assert.assertEquals("device flow must not run; a silent refresh suffices", 0, device.get());
+ Assert.assertTrue("the token endpoint must be hit for the refresh", token.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRestartServesPersistedIdTokenWithGroupsInToken() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ token.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ try (OidcDeviceAuth first = baseBuilder(server).groupsInToken(true).tokenStore(new FileTokenStore(dir)).build()) {
+ // with groups encoded in the token, signIn() serves the id token, and that is what persists
+ Assert.assertEquals("ID-1", first.signIn());
+ }
+ Assert.assertEquals(1, device.get());
+ // the persisted entry must record the id-token identity, so an access-token-mode client rejects it
+ String json = new String(Files.readAllBytes(dir.resolve(keyForGroups(server).hash() + ".json")), StandardCharsets.UTF_8);
+ Assert.assertTrue("file must record groups_in_token=true: " + json, json.contains("\"groups_in_token\":true"));
+ device.set(0);
+ token.set(0);
+
+ // a restart over the same store serves the persisted id token with no network
+ try (OidcDeviceAuth restarted = baseBuilder(server).groupsInToken(true).tokenStore(new FileTokenStore(dir)).build()) {
+ Assert.assertEquals("ID-1", restarted.signIn());
+ }
+ Assert.assertEquals("device flow must not run on restart", 0, device.get());
+ Assert.assertEquals("a valid persisted id token needs no token-endpoint call", 0, token.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRestartServesPersistedTokenWithoutNetwork() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ try (OidcDeviceAuth first = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ Assert.assertEquals("ACCESS-1", first.signIn());
+ }
+ Assert.assertEquals(1, device.get());
+ device.set(0);
+ token.set(0);
+
+ // a new instance over the same store mimics a restart: it serves the persisted (still valid)
+ // token with no calls to either endpoint
+ try (OidcDeviceAuth restarted = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ Assert.assertEquals("ACCESS-1", restarted.signIn());
+ }
+ Assert.assertEquals("device flow must not run on restart", 0, device.get());
+ Assert.assertEquals("a valid persisted token needs no token-endpoint call", 0, token.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRotatingRefreshRewritesStore() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.stored = new PersistedToken("OLD", null, "REFRESH-1", System.currentTimeMillis() - 60_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ }
+ Assert.assertEquals(0, device.get());
+ Assert.assertEquals("the coordinated refresh must run through the store's cross-process lock", 1, fake.locks.get());
+ Assert.assertEquals("a rotated refresh token must be persisted", 1, fake.saves.get());
+ Assert.assertEquals("REFRESH-2", fake.stored.getRefreshToken());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSaveFailureIsNonFatal() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.failSave = true;
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ // the store's save throws, but the failure is swallowed (warned best-effort, then ignored) and
+ // the sign-in still yields the valid in-memory token
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ }
+ // the save was actually attempted, so the throwing path was exercised rather than skipped
+ Assert.assertTrue("the token store save must have been attempted", fake.saves.get() >= 1);
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSaveFailureThenRefreshDoesNotReplayRevokedToken() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicBoolean refresh1Consumed = new AtomicBoolean();
+ // a rotating identity provider: REFRESH-1 mints REFRESH-2 once (and is then revoked, so replaying
+ // it is rejected); REFRESH-2 mints REFRESH-3. The first refreshed token is short-lived, forcing a
+ // second refresh while the rotated REFRESH-2 is still only in memory (every save fails).
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ if (body.contains("refresh_token=REFRESH-2")) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-3", null, "REFRESH-3", 3600));
+ }
+ if (body.contains("refresh_token=REFRESH-1")) {
+ if (refresh1Consumed.compareAndSet(false, true)) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 1));
+ }
+ // a rotated-away refresh token is revoked: replaying it must be rejected
+ return MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.failSave = true; // every persist fails, so the rotated REFRESH-2 never reaches disk
+ fake.stored = new PersistedToken("OLD-ACCESS", null, "REFRESH-1", System.currentTimeMillis() - 60_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ // first refresh rotates REFRESH-1 -> REFRESH-2 (save fails, so disk still says REFRESH-1)
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ // let the short-lived access token expire so the next call refreshes again
+ Thread.sleep(1_200);
+ // the second refresh must use the in-memory REFRESH-2, not re-read the stale (now revoked)
+ // REFRESH-1 from disk - otherwise the replay is rejected and we are forced to re-prompt
+ Assert.assertEquals("ACCESS-3", auth.signIn());
+ }
+ Assert.assertEquals("a swallowed save must not force the device flow on the next refresh", 0, device.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testStoreThrowingBeforeTheActionDegradesToOneUncoordinatedRefresh() throws Exception {
+ assertMemoryLeak(() -> {
+ // TokenStore is a user-implemented SPI and persistence is documented best-effort, but inLock was
+ // called bare: a store that threw took the whole sign-in down with it and refreshed nothing, even
+ // though the client held a perfectly good refresh token. The degrade is a single uncoordinated
+ // refresh - exactly one, because the lock exists to stop a rotating refresh token being POSTed
+ // twice, and a reuse-detecting provider answers a replay by revoking the whole family.
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-REFRESHED", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.stored = new PersistedToken("ACCESS-STALE", null, "REFRESH-1", System.currentTimeMillis() - 1, 300_000);
+ fake.throwBeforeAction = new RuntimeException("LOCK-DOWN");
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("a throwing store must not fail a sign-in it cannot help with",
+ "ACCESS-REFRESHED", auth.getToken());
+ }
+ Assert.assertEquals("the degrade must still refresh", 1, token.get());
+ Assert.assertEquals("the interactive flow must not be needed", 0, device.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testStoreThrowingAfterTheActionKeepsTheCompletedRefresh() throws Exception {
+ assertMemoryLeak(() -> {
+ // The mirror case, and the one where a blind retry does real damage. The store threw on the way
+ // OUT - releasing its lock, closing a handle - so the refresh already happened and the token is
+ // live. Re-running it would be the duplicate POST of a rotating refresh token the lock exists to
+ // prevent, and propagating would tell the caller a completed sign-in failed.
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-1", "REFRESH-1", "ACCESS-REFRESHED", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ fake.stored = new PersistedToken("ACCESS-STALE", null, "REFRESH-1", System.currentTimeMillis() - 1, 300_000);
+ fake.throwAfterAction = new RuntimeException("RELEASE-FAILED");
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("a completed refresh must be reported, not undone by a bookkeeping throw",
+ "ACCESS-REFRESHED", auth.getToken());
+ }
+ Assert.assertEquals("the refresh must not be replayed after it already completed",
+ 1, token.get());
+ Assert.assertEquals("the interactive flow must not be needed", 0, device.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testStoreLoadedAtMostOncePerInstance() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a valid persisted token, so every getToken()/signIn() is a cache hit
+ fake.loadReturns = new PersistedToken("ACCESS-1", null, "REFRESH-1",
+ System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ auth.getToken();
+ auth.signIn();
+ auth.getToken();
+ Assert.assertEquals("the store must be read at most once per instance, not on every call",
+ 1, fake.loads.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedBareJsonNullServedTokenIsRefusedNotServedAsBearerNull() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-FRESH", "REFRESH-FRESH", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ // Start from a file a conforming writer produced, so the fingerprint and the file name are
+ // exactly right, then replace the served token with a BARE JSON null - the one encoding
+ // design/oidc-token-persistence.md forbids, and the natural output of
+ // json.dumps({"access_token": None}) in a peer client sharing this store.
+ new FileTokenStore(dir).save(keyFor(server),
+ new PersistedToken("ACCESS-PLANTED", null, "REFRESH-1",
+ System.currentTimeMillis() + 300_000, 300_000));
+ Path file = dir.resolve(keyFor(server).hash() + ".json");
+ String conforming = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+ Assert.assertTrue("the writer must emit a present token as a QUOTED string, or this test is "
+ + "not planting what it thinks: " + conforming,
+ conforming.contains("\"access_token\":\"ACCESS-PLANTED\""));
+ Files.write(file, conforming
+ .replace("\"access_token\":\"ACCESS-PLANTED\"", "\"access_token\":null")
+ .getBytes(StandardCharsets.UTF_8));
+
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ // JsonLexer reports a bare null and a quoted "null" identically, so the entry reaches
+ // adopt() as the four characters "null" - non-blank, printable ASCII, and with a
+ // fingerprint that matches, so nothing before adopt() turns it away. Served, it becomes
+ // "Bearer null", which the server answers with 401; and because the persisted expiry is
+ // still valid, getToken() would go on serving it rather than refreshing, so the producer
+ // 401s with nothing naming the cause until the expiry lapses.
+ String result = auth.signIn();
+ Assert.assertEquals("ACCESS-FRESH", result);
+ Assert.assertNotEquals("a bare JSON null must never be served as the credential",
+ "null", result);
+ Assert.assertNotEquals("null", auth.getToken());
+ Assert.assertEquals("Bearer ACCESS-FRESH", auth.getAuthorizationHeaderValue());
+ }
+ Assert.assertTrue("a bare JSON null must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedBlankServedTokenRejectedOnLoad() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a tampered persisted entry with a BLANK (whitespace-only) served token is NOT isEmpty() and
+ // passes hasOnlyTokenChars vacuously (space is 0x20), yet is served as a blank "Bearer " header
+ // the server only answers with 401 - so adopt() must reject it (via Chars.isBlank, matching the
+ // sender's own HttpTokenProvider.validateToken) and fall back to the device flow, not wedge on it
+ fake.loadReturns = new PersistedToken(" ", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("ACCESS-FRESH", result);
+ Assert.assertNotEquals(" ", result);
+ }
+ Assert.assertTrue("a rejected blank persisted token must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedEmptyServedTokenRejectedOnLoad() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a tampered persisted entry with an EMPTY served token passes hasOnlyTokenChars vacuously but
+ // would be served as a blank "Bearer " header; adopt() must reject it (not serve "") and fall
+ // back to the device flow, exactly like a control-char token
+ fake.loadReturns = new PersistedToken("", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("ACCESS-FRESH", result);
+ Assert.assertNotEquals("", result);
+ }
+ Assert.assertTrue("a rejected empty persisted token must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedFarFutureExpiryIsBoundedNotTrustedForever() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-FRESH", "REFRESH-FRESH", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a tampered entry claims the access token never expires. adopt() must clamp the trust window
+ // to MAX_EXPIRES_IN_SECONDS rather than copy the far-future expiry verbatim, and the clamp
+ // arithmetic (now + maxLife, Math.min over the persisted value) must not overflow on
+ // Long.MAX_VALUE. Within the clamped hour the token is still valid, so it is served with no
+ // network.
+ fake.loadReturns = new PersistedToken("ACCESS-1", null, "REFRESH-1", Long.MAX_VALUE, Long.MAX_VALUE);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("a still-valid persisted token is served within the clamped window", "ACCESS-1", auth.signIn());
+ // assert the clamp actually bounds the trust window, not merely that it avoids overflow:
+ // a verbatim copy of the Long.MAX_VALUE expiry would still pass the assertion above but
+ // fail these. MAX_EXPIRES_IN_SECONDS is 3600, so the window is at most one hour from now.
+ long maxLifeMillis = 3_600_000L;
+ Assert.assertTrue("a tampered far-future expiry must be clamped to <= now + 1h",
+ readPrivateLong(auth, "expiresAtMillis") <= System.currentTimeMillis() + maxLifeMillis);
+ Assert.assertTrue("a tampered ttl must be clamped to <= 1h",
+ readPrivateLong(auth, "tokenTtlMillis") <= maxLifeMillis);
+ }
+ Assert.assertEquals("no device flow for a valid persisted token", 0, device.get());
+ Assert.assertEquals("no token-endpoint call for a valid persisted token", 0, token.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedFarPastExpiryIsNotServedAsValid() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-FRESH", "REFRESH-FRESH", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a tampered entry claims an absurd, far-PAST expiry near Long.MIN_VALUE. adopt() clamps the
+ // expiry to [0, now + maxLife]; flooring at 0 is what keeps the validity check
+ // (now < expiresAtMillis - skew) underflow-safe - without the floor a near-Long.MIN_VALUE
+ // expiry wraps that subtraction to a huge positive and would serve the garbage-expiry token as
+ // valid forever. It must instead read as expired and fall back to a silent refresh.
+ fake.loadReturns = new PersistedToken("ACCESS-1", null, "REFRESH-1", Long.MIN_VALUE, Long.MIN_VALUE);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("a far-past expiry must not be served; the refresh token supplies a fresh one", "ACCESS-2", result);
+ Assert.assertNotEquals("a garbage-expiry token must never be served as valid", "ACCESS-1", result);
+ }
+ Assert.assertEquals("a valid refresh token needs no device flow", 0, device.get());
+ Assert.assertTrue("the expired persisted token must trigger a token-endpoint refresh", token.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedFileWithCrlfTokenFallsBackToDeviceFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-FRESH", "REFRESH-FRESH", "ACCESS-2", "REFRESH-2");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ // a genuine on-disk file (valid fingerprint) whose served token carries CR/LF: the JSON writer
+ // escapes it and the lexer decodes it back to real control bytes on load, so adopt() must
+ // reject it and fall back rather than route a header-injecting credential onto the wire
+ new FileTokenStore(dir).save(keyFor(server),
+ new PersistedToken("AC\r\nCESS", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000));
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir)).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("ACCESS-FRESH", result);
+ Assert.assertNotEquals("AC\r\nCESS", result);
+ }
+ Assert.assertTrue("a rejected on-disk token must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedIdTokenRejectedOnLoadWithGroupsInToken() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ token.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", "ID-FRESH", "REFRESH-FRESH", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ Path dir = storeDir();
+ // groups-in-token mode serves the ID token, so adopt() must validate the ID token, not the access
+ // token. A genuine on-disk file (valid groups fingerprint) with a CLEAN access token but a CR/LF
+ // id token must be rejected and fall back to the device flow, never routing the tampered id token
+ // onto the wire. A bug that validated the access token would accept this entry and serve "I\r\nD".
+ new FileTokenStore(dir).save(keyForGroups(server),
+ new PersistedToken("ACCESS-CLEAN", "I\r\nD", "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000));
+ try (OidcDeviceAuth auth = baseBuilder(server).groupsInToken(true).tokenStore(new FileTokenStore(dir)).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("ID-FRESH", result);
+ Assert.assertNotEquals("I\r\nD", result);
+ }
+ Assert.assertTrue("a rejected on-disk id token must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedServedTokenRejectedOnLoad() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // a tampered persisted access token carrying CR/LF must never be served
+ fake.loadReturns = new PersistedToken("AC\r\nCESS", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("ACCESS-FRESH", result);
+ Assert.assertNotEquals("AC\r\nCESS", result);
+ }
+ Assert.assertTrue("a rejected persisted token must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTamperedServedTokenWithNonAsciiRejectedOnLoad() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // hasOnlyTokenChars rejects a non-ASCII char (> 0x7e), not just a control char: a persisted served
+ // token carrying one (here U+00E9) is the byte the ASCII Authorization-header writer would
+ // truncate, so adopt() must reject the entry and fall back rather than serve a corrupt credential
+ fake.loadReturns = new PersistedToken("ACC\u00e9SS", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ String result = auth.signIn();
+ Assert.assertEquals("ACCESS-FRESH", result);
+ Assert.assertNotEquals("ACC\u00e9SS", result);
+ }
+ Assert.assertTrue("a rejected non-ASCII persisted token must fall back to the device flow", device.get() >= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPersistedEntryWithoutServedTokenStillRefreshes() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ AtomicInteger token = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ token.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-REFRESHED", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // An entry with a good refresh token but no SERVED kind is reachable: under
+ // groupsInToken=false a grant that returns only an id_token has storeTokens null the access
+ // token, and persistIfRotated writes the entry anyway - and a cross-language peer can produce
+ // the same shape. adopt() used to discard such an entry whole, throwing away the refresh
+ // token, which is the one thing persistence exists to preserve. The restart must therefore
+ // spend one silent refresh, not send a human back through the device flow.
+ fake.loadReturns = new PersistedToken(null, "ID-1", "REFRESH-1",
+ System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("ACCESS-REFRESHED", auth.signIn());
+ }
+ Assert.assertEquals("the persisted refresh token must be spent on a silent refresh",
+ 1, token.get());
+ Assert.assertEquals("a usable persisted refresh token must not force the device flow",
+ 0, device.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTransientStoreLoadFailureIsRetriedNotLatched() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // The first read fails transiently - the shape a carried interrupt flag produces, since
+ // FileChannel is an InterruptibleChannel and throws ClosedByInterruptException on a thread
+ // that merely carries the flag. Latching "already attempted" on that failure disables
+ // persistence for the whole life of the instance, so a process holding a perfectly good
+ // refresh token on disk re-runs the interactive device flow instead - a hard failure for the
+ // headless getToken() consumer persistence exists for, not a degraded one. Only a read that
+ // COMPLETES (even yielding nothing) is a definitive answer worth latching.
+ fake.failLoadTimes = 1;
+ fake.loadReturns = new PersistedToken("ACCESS-PERSISTED", null, "REFRESH-1",
+ System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ try {
+ auth.getToken();
+ Assert.fail("the first call must report no usable token after the read failed");
+ } catch (OidcAuthException expected) {
+ // the read threw, so nothing was adopted and there is no token to serve yet
+ }
+ Assert.assertEquals("the failed read must not be retried within one call", 1, fake.loads.get());
+
+ // the store recovers: the very next call must re-read it and serve the persisted token
+ Assert.assertEquals("ACCESS-PERSISTED", auth.getToken());
+ Assert.assertEquals("a failed read must leave the store re-readable", 2, fake.loads.get());
+ Assert.assertEquals("a recovered store must not force the interactive device flow",
+ 0, device.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRepeatedStoreLoadFailureIsThrottledNotRetriedOnEveryCall() throws Exception {
+ assertMemoryLeak(() -> {
+ // A store that never becomes readable - a chmod or uid mismatch in a container, EIO/ESTALE on an
+ // NFS home - must not cost a blocking read on every call. maybeLoadFromStore() runs on the
+ // getToken() path AHEAD of the cache check, and getToken() runs once per ILP flush, so without a
+ // back-off the producer thread paid a file open, two stack trace fills and a WARN line per flush,
+ // forever, while holding this instance's lock.
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // never recovers, unlike the single transient fault above
+ fake.failLoadTimes = Integer.MAX_VALUE;
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ for (int i = 0; i < 25; i++) {
+ try {
+ auth.getToken();
+ Assert.fail("an unreadable store leaves no token to serve");
+ } catch (OidcAuthException expected) {
+ // every call still reports the failure - only the store read is rate-limited
+ }
+ }
+ // two reads, not 25: the first failure, plus the free retry it arms so a one-shot fault
+ // still recovers at once. The second failure arms the real back-off, which the remaining
+ // 23 calls run inside of.
+ Assert.assertEquals("25 getToken() calls must not mean 25 store reads", 2, fake.loads.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testARotatedRefreshWithNoServedKindIsNotPersistedAsAnUnadoptableEntry() throws Exception {
+ assertMemoryLeak(() -> {
+ // adopt() rejects a refresh token carried with NEITHER token kind, treating it as positive
+ // evidence of a foreign writer - an attacker who can write the store dropping in their own
+ // refresh token. That reasoning only holds while this client cannot produce the shape.
+ //
+ // It can. Under groupsInToken the served kind is the id token, so a stored entry carrying only
+ // an access token takes adopt()'s served-kind-absent branch, which nulls BOTH kinds and keeps
+ // the refresh token. A refresh that then rotates the refresh token but still returns no id
+ // token reaches adoptRotatedRefreshToken() -> persistIfRotated() with both null, and writing
+ // that snapshot leaves a file this client refuses for the life of the entry: every restart
+ // re-runs the device flow over a refresh token sitting on disk, which for a headless
+ // getToken() consumer is a hard failure.
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ // rotates the refresh token, still no id_token: the grant the branch above exists for
+ return MockOidcServer.json(200, tokenJson("ACCESS-NEW", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ long now = System.currentTimeMillis();
+ PersistedToken seeded = new PersistedToken("ACCESS-OLD", null, "REFRESH-1",
+ now + 300_000, 300_000);
+ fake.stored = seeded;
+ fake.loadReturns = seeded;
+ try (OidcDeviceAuth auth = baseBuilder(server).groupsInToken(true).tokenStore(fake).build()) {
+ try {
+ // no id token anywhere: the seeded entry has none and the refresh does not produce
+ // one, so this necessarily fails - the point is what it leaves on disk
+ auth.getToken();
+ Assert.fail("groupsInToken with no id token must not yield a served token");
+ } catch (OidcAuthException expected) {
+ // expected: selectToken() reports the missing served kind
+ }
+ Assert.assertEquals("the refresh must have run, or this test proves nothing about what "
+ + "adoptRotatedRefreshToken() persists", 0, device.get());
+
+ PersistedToken after = fake.stored;
+ Assert.assertNotNull("the pre-existing entry must not be replaced by nothing", after);
+ Assert.assertFalse("the client must never persist the one shape adopt() rejects as "
+ + "foreign: a refresh token with neither token kind",
+ after.getAccessToken() == null && after.getIdToken() == null);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testARecoveredStoreReadDoesNotRevertACompletedSignIn() throws Exception {
+ assertMemoryLeak(() -> {
+ // maybeLoadFromStore() deliberately leaves its latch UNSET when a read THROWS, so a transient
+ // fault is retried rather than disabling persistence for the life of the instance. But it runs
+ // at the top of getToken(), AHEAD of the cache check, and adopt() assigns the served kind, the
+ // expiry and the ttl unconditionally - with no comparison against what is already in memory.
+ //
+ // So a store that is unavailable across signIn() and readable afterwards used to undo it: an
+ // unmounted home or a container started before its volume attaches fails the read AND the save
+ // (one root cause, both through ensureDirectory), the human authenticates, and then the next
+ // getToken() - one per ILP flush - re-reads and installs the PREVIOUS entry over the grant just
+ // obtained. The failed save is what makes it stick: nothing rewrote the entry to match memory.
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-FRESH", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ long now = System.currentTimeMillis();
+ // a PREVIOUS login, still unexpired, so adopt() would take it and getToken() would serve it
+ fake.loadReturns = new PersistedToken("ACCESS-STALE", null, "REFRESH-STALE",
+ now + 300_000, 300_000);
+ fake.failLoadTimes = 1; // the read inside signIn()
+ fake.failSave = true; // ...and the save that follows it, same root cause
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ Assert.assertEquals("ACCESS-FRESH", auth.signIn());
+ Assert.assertEquals("the device flow must have run", 1, device.get());
+ Assert.assertTrue("the save must have been attempted and failed, or this test is not "
+ + "reproducing the stuck-stale-entry case", fake.saves.get() > 0);
+
+ // the store is readable again from here on (failLoadTimes is spent)
+ Assert.assertEquals("a recovered store read must not install a previous login over the "
+ + "grant signIn() just obtained", "ACCESS-FRESH", auth.getToken());
+ Assert.assertEquals("ACCESS-FRESH", auth.getToken());
+ Assert.assertEquals("once this instance holds its own tokens the store is no longer "
+ + "authoritative for it and must not be re-read", 1, fake.loads.get());
+ }
+ }
+ });
+ }
+
+
+ @Test(timeout = 30_000)
+ public void testSignInClearsTheStoreLoadBackOff() throws Exception {
+ assertMemoryLeak(() -> {
+ // The back-off must never strand a caller: signIn() is the explicit action a user takes to
+ // recover, and sending a human through the device flow over a refresh token that is sitting on
+ // disk - readable again by then - is exactly what persistence exists to avoid.
+ AtomicInteger device = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ FakeTokenStore fake = new FakeTokenStore();
+ // the two getToken() reads fail, arming the back-off; the store is readable again by the time
+ // signIn() is called
+ fake.failLoadTimes = 2;
+ fake.loadReturns = new PersistedToken("ACCESS-PERSISTED", null, "REFRESH-1",
+ System.currentTimeMillis() + 300_000, 300_000);
+ try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
+ for (int i = 0; i < 2; i++) {
+ try {
+ auth.getToken();
+ Assert.fail("the store read failed, so there is no token to serve yet");
+ } catch (OidcAuthException expected) {
+ // the second failure arms the back-off
+ }
+ }
+ Assert.assertEquals(2, fake.loads.get());
+
+ Assert.assertEquals("ACCESS-PERSISTED", auth.signIn());
+ Assert.assertEquals("signIn() must re-read a store the back-off is holding off",
+ 3, fake.loads.get());
+ Assert.assertEquals("a readable store must not force the interactive device flow",
+ 0, device.get());
+ }
+ }
+ });
+ }
+
+ private static OidcDeviceAuth.Builder baseBuilder(MockOidcServer server) {
+ return OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .scope("openid")
+ .allowInsecureTransport(true)
+ .prompt(challenge -> {
+ });
+ }
+
+ private static MockOidcServer.Handler countingHandler(
+ AtomicInteger device, AtomicInteger token,
+ String access, String refresh, String refreshedAccess, String refreshedRefresh
+ ) {
+ return (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ device.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthJson());
+ }
+ token.incrementAndGet();
+ if (body.contains("grant_type=refresh_token")) {
+ return MockOidcServer.json(200, tokenJson(refreshedAccess, null, refreshedRefresh, 3600));
+ }
+ return MockOidcServer.json(200, tokenJson(access, null, refresh, 3600));
+ };
+ }
+
+ private static String deviceAuthJson() {
+ return "{\"device_code\":\"DEV-CODE\",\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\",\"expires_in\":300,\"interval\":1}";
+ }
+
+ private static TokenStoreKey keyFor(MockOidcServer server) {
+ return new TokenStoreKey(
+ "questdb",
+ "http://127.0.0.1:" + server.port() + TOKEN_PATH,
+ "http://127.0.0.1:" + server.port() + DEVICE_PATH,
+ "openid",
+ null,
+ false);
+ }
+
+ private static TokenStoreKey keyForGroups(MockOidcServer server) {
+ return new TokenStoreKey(
+ "questdb",
+ "http://127.0.0.1:" + server.port() + TOKEN_PATH,
+ "http://127.0.0.1:" + server.port() + DEVICE_PATH,
+ "openid",
+ null,
+ true);
+ }
+
+ private static long readPrivateLong(Object target, String fieldName) throws Exception {
+ java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.getLong(target);
+ }
+
+ private static String tokenJson(String access, String id, String refresh, int expiresIn) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("{\"token_type\":\"Bearer\",\"expires_in\":").append(expiresIn);
+ if (access != null) {
+ sb.append(",\"access_token\":\"").append(access).append('"');
+ }
+ if (id != null) {
+ sb.append(",\"id_token\":\"").append(id).append('"');
+ }
+ if (refresh != null) {
+ sb.append(",\"refresh_token\":\"").append(refresh).append('"');
+ }
+ sb.append('}');
+ return sb.toString();
+ }
+
+ private Path storeDir() {
+ return temp.getRoot().toPath().resolve("oidc-tokens");
+ }
+
+ private static final class FakeTokenStore implements TokenStore {
+ final AtomicInteger clears = new AtomicInteger();
+ final AtomicInteger loads = new AtomicInteger();
+ final AtomicInteger locks = new AtomicInteger();
+ final AtomicInteger saves = new AtomicInteger();
+ // models a CONFORMANT coordinating store whose lock wait a cancellation abandoned: per TokenStore's
+ // contract it returns false without running the action AND leaves the interrupt flag set, which is
+ // what lets OidcDeviceAuth tell that apart from a refresh that ran and failed
+ boolean cancelWaitInsteadOfRunning;
+ // number of leading load() calls to fail before the first one is allowed to succeed; models a
+ // transient store fault (an interrupted channel, a momentary IO error), as opposed to a store that
+ // simply has nothing to return
+ int failLoadTimes;
+ boolean failSave;
+ PersistedToken loadReturns;
+ PersistedToken peerInstallsOnLock;
+ PersistedToken stored;
+ RuntimeException throwAfterAction;
+ RuntimeException throwBeforeAction;
+
+ @Override
+ public void clear(TokenStoreKey key) {
+ clears.incrementAndGet();
+ stored = null;
+ }
+
+ @Override
+ public boolean inLock(TokenStoreKey key, CriticalSection action) {
+ locks.incrementAndGet();
+ if (throwBeforeAction != null) {
+ throw throwBeforeAction;
+ }
+ if (cancelWaitInsteadOfRunning) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ if (peerInstallsOnLock != null) {
+ // simulate a peer process refreshing and writing a fresh entry while we hold the lock
+ stored = peerInstallsOnLock;
+ peerInstallsOnLock = null;
+ }
+ boolean result = action.run();
+ if (throwAfterAction != null) {
+ // a bookkeeping failure on the way out - releasing the lock, closing a handle - AFTER the
+ // critical section already completed
+ throw throwAfterAction;
+ }
+ return result;
+ }
+
+ @Override
+ public PersistedToken load(TokenStoreKey key) {
+ loads.incrementAndGet();
+ if (failLoadTimes > 0) {
+ failLoadTimes--;
+ throw new RuntimeException("token store read failed");
+ }
+ return loadReturns != null ? loadReturns : stored;
+ }
+
+ @Override
+ public void save(TokenStoreKey key, PersistedToken token) {
+ saves.incrementAndGet();
+ if (failSave) {
+ throw new RuntimeException("disk full");
+ }
+ stored = token;
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java
new file mode 100644
index 000000000..eccf2e0ad
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java
@@ -0,0 +1,4395 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.auth.DeviceAuthorizationChallenge;
+import io.questdb.client.cutlass.auth.DeviceCodePrompt;
+import io.questdb.client.cutlass.auth.OidcAuthException;
+import io.questdb.client.cutlass.auth.OidcDeviceAuth;
+import io.questdb.client.cutlass.json.JsonException;
+import io.questdb.client.cutlass.http.client.Fragment;
+import io.questdb.client.cutlass.http.client.HttpClientException;
+import io.questdb.client.cutlass.http.client.Response;
+import io.questdb.client.cutlass.json.JsonLexer;
+import io.questdb.client.cutlass.json.JsonParser;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Os;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.std.str.StringSink;
+import io.questdb.client.test.tools.NoBrowserLaunch;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+public class OidcDeviceAuthTest {
+
+ /**
+ * Every flow here that reaches the device-code prompt would otherwise pop a real browser tab on a
+ * developer machine. A class rule rather than a static initializer, so the override is undone
+ * afterwards instead of leaking into every later class in the surefire JVM.
+ */
+ @ClassRule
+ public static final NoBrowserLaunch NO_BROWSER = new NoBrowserLaunch();
+
+ private static final String DEVICE_PATH = "/device";
+ private static final JsonParser NOOP_JSON_PARSER = (code, tag, position) -> {
+ };
+ private static final String SETTINGS_PATH = "/settings";
+ private static final String TOKEN_PATH = "/token";
+ private static final String WELL_KNOWN_PATH = "/.well-known/openid-configuration";
+
+ @Test(timeout = 30_000)
+ public void testAccessDeniedSurfacesOauthError() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"the user declined\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "the user declined");
+ Assert.assertEquals("access_denied", e.getOauthError());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testAllControlVerificationUriCompleteTreatedAsAbsent() throws Exception {
+ assertMemoryLeak(() -> {
+ // a verification_uri_complete that is all control chars is non-empty on the wire but sanitizes to
+ // empty; it must be treated as absent (null), so the prompt shows no blank "(or open this URL ...)"
+ // line and the browser launcher is never handed an empty string
+ String allControl = jsonUnicodeEscape(0x0001) + jsonUnicodeEscape(0x0002) + jsonUnicodeEscape(0x0003);
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"verification_uri_complete\":\"" + allControl + "\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ Assert.assertNull(challenge.getVerificationUriComplete());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testAllControlVerificationUriRejectedAsIncomplete() throws Exception {
+ assertMemoryLeak(() -> {
+ // a verification_uri made entirely of control chars is non-empty on the wire but sanitizes to empty
+ // - it would display as a blank URL the user cannot open, so the response is rejected as incomplete
+ // (the valid token below would let an unfixed client proceed to a successful but unusable sign-in)
+ String allControl = jsonUnicodeEscape(0x0001) + jsonUnicodeEscape(0x0002);
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"" + allControl + "\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "incomplete",
+ "expected an all-control verification_uri to be rejected as incomplete");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testAudienceParameterSentToDeviceEndpoint() throws Exception {
+ assertMemoryLeak(() -> {
+ // the optional audience builder parameter must be url-encoded into the device authorization request
+ AtomicReference deviceBody = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceBody.set(body);
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-AUD", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .audience("api://questdb")
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ Assert.assertEquals("ACCESS-AUD", auth.signIn());
+ Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testAudienceSentOnRefresh() throws Exception {
+ assertMemoryLeak(() -> {
+ // the audience must also be url-encoded into the refresh request, matching the Python client
+ AtomicReference refreshBody = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ refreshBody.set(body);
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 60));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .audience("api://questdb")
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth); // force the silent-refresh path on the next call
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ Assert.assertTrue(refreshBody.get(), refreshBody.get().contains("audience=api%3A%2F%2Fquestdb"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderIssuerPinAcceptsHostCasingAndImplicitPort() throws Exception {
+ assertMemoryLeak(() -> {
+ // the origin pin (isSameOrigin) folds host case (ASCII) and treats an implicit https port as 443, so
+ // an endpoint differing from the issuer only in host case or an explicit :443 is still same-origin
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://IDP.Example:443/as/device")
+ .tokenEndpoint("https://idp.example/as/token")
+ .issuer("https://Idp.Example")
+ .build()
+ ) {
+ // accepted: host-case and implicit-vs-explicit 443 differences do not defeat the origin pin
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderIssuerPinAcceptsMatchingOrigin() throws Exception {
+ assertMemoryLeak(() -> {
+ // endpoints that belong to the pinned issuer origin are accepted; only the origin is pinned, so
+ // the differing paths of the device and token endpoints are fine
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://idp.example/as/device")
+ .tokenEndpoint("https://idp.example/as/token")
+ .issuer("https://idp.example")
+ .build()
+ ) {
+ // accepted: build() did not reject the matching-origin endpoints
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderIssuerPinRejectsOffOriginEndpoints() {
+ // the token/device endpoints do not belong to the pinned issuer origin; build() must reject them
+ // rather than send the device code and refresh token outside the trusted issuer
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://idp.example/device")
+ .tokenEndpoint("https://idp.example/token")
+ .issuer("https://other-idp.example")
+ .build()
+ ) {
+ Assert.fail("expected the issuer pin to reject off-origin endpoints");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin"));
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderRejectsMissingRequiredOptions() {
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder().deviceAuthorizationEndpoint("https://h/d").tokenEndpoint("https://h/t").build()) {
+ Assert.fail("expected clientId validation to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("clientId"));
+ }
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder().clientId("c").tokenEndpoint("https://h/t").build()) {
+ Assert.fail("expected deviceAuthorizationEndpoint validation to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("deviceAuthorizationEndpoint"));
+ }
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder().clientId("c").deviceAuthorizationEndpoint("https://h/d").build()) {
+ Assert.fail("expected tokenEndpoint validation to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("tokenEndpoint"));
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderRejectsNonPositiveHttpTimeout() {
+ // every other timing input is clamped; a non-positive HTTP timeout yields an already-expired read
+ // deadline and an unbounded recv(int), so the setter rejects it (matching Sender.Builder)
+ for (int bad : new int[]{0, -1}) {
+ try {
+ OidcDeviceAuth.builder().httpTimeoutMillis(bad);
+ Assert.fail("expected httpTimeoutMillis(" + bad + ") to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpTimeoutMillis"));
+ }
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testBuilderRejectsSplitOriginEndpoints() {
+ // the token and device authorization endpoints are on different origins; RFC 8628 co-locates them
+ // on one authorization server, so build() must refuse to spread the credential POSTs across hosts
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://device.example/device")
+ .tokenEndpoint("https://token.example/token")
+ .build()
+ ) {
+ Assert.fail("expected split-origin endpoints to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origins"));
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testChallengeStripsBidiAndZeroWidthFromDisplayFields() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile or MITM'd IdP smuggles bidi/zero-width formatting into the display fields. Here a
+ // right-to-left override (U+202E) arrives as a JSON unicode escape, which this client's lexer
+ // decodes into the real character before it reaches the prompt; a BOM, a zero-width space and a
+ // bidi isolate arrive the same way. The challenge shown to the user must strip them all, so the
+ // verification URL a human reads matches the one their browser opens
+ String evilUri = "https://verify.example/" + jsonUnicodeEscape(0x202E) + "evil"; // RTL override
+ String evilComplete = "https://verify.example/" + jsonUnicodeEscape(0xFEFF) + "device?x=1"; // BOM
+ String evilUserCode = "W" + jsonUnicodeEscape(0x200B) + "D" + jsonUnicodeEscape(0x2066) + "JB"; // ZWSP + LRI
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"" + evilUserCode + "\","
+ + "\"verification_uri\":\"" + evilUri + "\","
+ + "\"verification_uri_complete\":\"" + evilComplete + "\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ // the bidi/zero-width/BOM characters are removed, the readable text is preserved
+ Assert.assertEquals("https://verify.example/evil", challenge.getVerificationUri());
+ Assert.assertEquals("https://verify.example/device?x=1", challenge.getVerificationUriComplete());
+ Assert.assertEquals("WDJB", challenge.getUserCode());
+ assertNoUnsafeDisplayChars(challenge.getUserCode());
+ assertNoUnsafeDisplayChars(challenge.getVerificationUri());
+ assertNoUnsafeDisplayChars(challenge.getVerificationUriComplete());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testChallengeStripsControlCharactersFromDisplayFields() throws Exception {
+ assertMemoryLeak(() -> {
+ // an attacker-influenced device-auth response embeds ANSI/control characters; the challenge
+ // shown to the user must have them stripped so it cannot rewrite or spoof the terminal
+ String evilUserCode = "WD\u001b[2JJB"; // ESC clear-screen sequence
+ String evilUri = "https://verify.example/\r\nFAKE: enter 000"; // CRLF line injection
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"" + evilUserCode + "\","
+ + "\"verification_uri\":\"" + evilUri + "\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ // the control characters are removed, the rest of the value is preserved
+ Assert.assertEquals("WD[2JJB", challenge.getUserCode());
+ Assert.assertEquals("https://verify.example/FAKE: enter 000", challenge.getVerificationUri());
+ assertNoControlChars(challenge.getUserCode());
+ assertNoControlChars(challenge.getVerificationUri());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testChallengeStripsLoneSurrogates() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile IdP smuggles unpaired UTF-16 surrogates into display fields via single backslash-u-XXXX escapes
+ // the lexer emits verbatim (it does not pair them). codePointAt surfaces a lone surrogate as a
+ // SURROGATE code point, which the sanitizer must strip - while a legitimate adjacent high+low pair
+ // (an emoji) that codePointAt reassembles survives.
+ String loneHigh = jsonUnicodeEscape(0xD83D); // high surrogate, no low half
+ String loneLow = jsonUnicodeEscape(0xDE00); // low surrogate, no high half
+ String emoji = jsonUnicodeEscape(0xD83D) + jsonUnicodeEscape(0xDE00); // U+1F600, a valid pair
+ String evilUserCode = "WD" + loneHigh + "JB";
+ String evilUri = "https://verify.example/" + loneLow + "evil" + emoji;
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"" + evilUserCode + "\","
+ + "\"verification_uri\":\"" + evilUri + "\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ // the unpaired surrogates are removed; the readable text and the legitimate emoji survive
+ Assert.assertEquals("WDJB", challenge.getUserCode());
+ Assert.assertEquals("https://verify.example/evil" + new String(Character.toChars(0x1F600)),
+ challenge.getVerificationUri());
+ assertNoUnsafeDisplayChars(challenge.getUserCode());
+ assertNoUnsafeDisplayChars(challenge.getVerificationUri());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testChallengeStripsSupplementaryPlaneFormatChars() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile IdP smuggles a supplementary-plane (>= U+10000) format char - U+E0001 LANGUAGE TAG,
+ // an invisible Unicode "tag" character (category Cf) used to hide or spoof text - via a
+ // surrogate-pair JSON unicode escape the lexer reassembles. A per-UTF-16-unit filter misses it
+ // (each surrogate half is neither a control nor Cf); the sanitizer must judge it per code point
+ // and strip it, while leaving a legitimate astral character (an emoji) intact.
+ String evilTag = jsonUnicodeEscape(0xDB40) + jsonUnicodeEscape(0xDC01); // U+E0001 as a surrogate pair
+ String emoji = jsonUnicodeEscape(0xD83D) + jsonUnicodeEscape(0xDE00); // U+1F600 grinning face
+ String evilUserCode = "WD" + evilTag + "JB";
+ String evilUri = "https://verify.example/" + evilTag + "evil" + emoji;
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"" + evilUserCode + "\","
+ + "\"verification_uri\":\"" + evilUri + "\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ // the invisible tag char is removed; the readable text and the legitimate emoji survive
+ Assert.assertEquals("WDJB", challenge.getUserCode());
+ Assert.assertEquals("https://verify.example/evil" + new String(Character.toChars(0x1F600)),
+ challenge.getVerificationUri());
+ assertNoUnsafeDisplayChars(challenge.getUserCode());
+ assertNoUnsafeDisplayChars(challenge.getVerificationUri());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testChunkedTokenResponseParses() throws Exception {
+ assertMemoryLeak(() -> {
+ // real IdPs use Transfer-Encoding: chunked; a multi-KB id token split across chunks must parse
+ String idToken = TestUtils.repeat("a", 3000);
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.chunkedJson(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.chunkedJson(200, tokenJson("ACCESS-CHUNKED", idToken, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) {
+ // groups-in-token mode serves the id token; it arrived chunked and is 3 KB long
+ Assert.assertEquals(idToken, auth.signIn());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testClearCacheForcesFreshSignIn() throws Exception {
+ assertMemoryLeak(() -> {
+ // clearCache() must drop the cached token AND the refresh token, so the next signIn() runs a
+ // fresh interactive sign-in (a device-code grant) rather than a silent refresh
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger refreshCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ refreshCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-R", null, "REFRESH-R", 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ auth.clearCache();
+ // the next call must run a second device-code sign-in, not a refresh (the refresh token was dropped)
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ Assert.assertEquals("clearCache must force a second interactive sign-in", 2, deviceCalls.get());
+ Assert.assertEquals("clearCache must drop the refresh token so no refresh is attempted", 0, refreshCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testClockSkewCappedAtHalfTokenLifetime() throws Exception {
+ assertMemoryLeak(() -> {
+ // the fixed 30s clock skew is capped at half the token lifetime (matching the Python client), so a
+ // short-lived token is served from cache for the first half of its life rather than being treated
+ // as expired the instant it is issued - which a flat 30s skew would do to any sub-60s token
+ AtomicInteger refreshCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ refreshCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 10)); // 10s lifetime
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ // a flat 30s skew would mark this 10s token expired immediately (now < expiresAt - 30s is
+ // false); the lifetime/2 cap (5s) keeps it valid, so the second call is a cache hit, not a refresh
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ Assert.assertEquals("the capped skew kept the short token cached - no refresh", 0, refreshCalls.get());
+
+ // once the token is genuinely past expiry, signIn() takes the silent-refresh path
+ expireCachedToken(auth);
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ Assert.assertEquals(1, refreshCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testCloseCancelsInFlightSignIn() throws Exception {
+ // a sign-in is waiting for the user: the token endpoint keeps returning authorization_pending.
+ // close() from another caller must abort the in-flight signIn() promptly, instead of letting
+ // it hold the instance lock and poll until the device code expires
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 10));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}");
+ };
+ CountDownLatch polling = new CountDownLatch(1);
+ AtomicReference outcome = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, challenge -> polling.countDown())) {
+ Thread signIn = new Thread(() -> {
+ try {
+ auth.signIn();
+ outcome.set(new AssertionError("signIn() should have been cancelled by close()"));
+ } catch (Throwable t) {
+ outcome.set(t);
+ }
+ }, "oidc-sign-in");
+ signIn.setDaemon(true);
+ signIn.start();
+ // wait until the flow has prompted and is polling, then close from this thread
+ Assert.assertTrue("the sign-in did not reach the polling stage", polling.await(10, TimeUnit.SECONDS));
+ auth.close();
+ signIn.join(10_000);
+ Assert.assertFalse("signIn() did not return promptly after close()", signIn.isAlive());
+ Throwable t = outcome.get();
+ Assert.assertTrue("expected an OidcAuthException, got " + t, t instanceof OidcAuthException);
+ Assert.assertTrue(t.getMessage(), t.getMessage().contains("closed"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testConcurrentSignInStartsSingleSignIn() throws Exception {
+ assertMemoryLeak(() -> {
+ // several callers race signIn() on a fresh instance; the synchronized method must serialize
+ // them so exactly one interactive sign-in runs and the rest get the cached token
+ AtomicInteger deviceCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-CONCURRENT", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ int workerCount = 4;
+ CountDownLatch ready = new CountDownLatch(workerCount);
+ CountDownLatch go = new CountDownLatch(1);
+ AtomicReference error = new AtomicReference<>();
+ String[] tokens = new String[workerCount];
+ Thread[] workers = new Thread[workerCount];
+ for (int i = 0; i < workerCount; i++) {
+ final int idx = i;
+ workers[i] = new Thread(() -> {
+ ready.countDown();
+ try {
+ go.await();
+ tokens[idx] = auth.signIn();
+ } catch (Throwable t) {
+ error.set(t);
+ }
+ }, "oidc-signIn-" + i);
+ workers[i].setDaemon(true);
+ workers[i].start();
+ }
+ Assert.assertTrue(ready.await(10, TimeUnit.SECONDS));
+ go.countDown();
+ for (Thread w : workers) {
+ w.join(10_000);
+ }
+ Assert.assertNull("a worker failed: " + error.get(), error.get());
+ Assert.assertEquals("only one interactive sign-in must run", 1, deviceCalls.get());
+ for (int i = 0; i < workerCount; i++) {
+ Assert.assertEquals("ACCESS-CONCURRENT", tokens[i]);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDeviceCodeLifetimeClamped() throws Exception {
+ assertMemoryLeak(() -> {
+ // a missing or zero expires_in defaults to 600s, and an absurd value is capped at 1800s (matching
+ // the Python client), so a hostile or buggy provider cannot make the client poll for an absurd
+ // duration; the clamped value is the one shown to the user (challenge.getExpiresInSeconds())
+ AtomicReference shown = new AtomicReference<>();
+ MockOidcServer.Handler missingExpiry = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"device_code\":\"DEV\",\"user_code\":\"UC\","
+ + "\"verification_uri\":\"https://verify.example/device\",\"interval\":1}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-DEFAULT-TTL", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(missingExpiry);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-DEFAULT-TTL", auth.signIn());
+ Assert.assertEquals(600, shown.get().getExpiresInSeconds());
+ }
+ MockOidcServer.Handler absurdExpiry = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 999_999));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-CAPPED-TTL", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(absurdExpiry);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-CAPPED-TTL", auth.signIn());
+ Assert.assertEquals(1800, shown.get().getExpiresInSeconds());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDeviceEndpointReturnsOauthError() throws Exception {
+ assertMemoryLeak(() -> {
+ // the device authorization request itself is rejected (e.g. the client is not allowed)
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(400, "{\"error\":\"invalid_client\",\"error_description\":\"unknown client\"}");
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "unknown client");
+ Assert.assertEquals("invalid_client", e.getOauthError());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDeviceFlowHappyPath() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ Assert.assertTrue(body, body.contains("client_id=questdb"));
+ Assert.assertTrue(body, body.contains("scope=openid"));
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ // first poll: still pending, second poll: success
+ Assert.assertTrue(body, body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"));
+ Assert.assertTrue(body, body.contains("device_code=DEV-CODE"));
+ if (tokenCalls.getAndIncrement() == 0) {
+ return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ Assert.assertEquals("Bearer ACCESS-1", auth.getAuthorizationHeaderValue());
+ Assert.assertEquals(2, tokenCalls.get());
+
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ Assert.assertEquals("WDJB-MJHT", challenge.getUserCode());
+ Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri());
+ Assert.assertEquals("https://verify.example/device?user_code=WDJB-MJHT", challenge.getVerificationUriComplete());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testMalformedResponseHeadDuringDiscoveryIsAnOidcAuthException() throws Exception {
+ assertMemoryLeak(() -> {
+ // HttpHeaderParser rejects a response head it cannot parse - here a header block past its fixed
+ // 4096-byte buffer, the shape a WAF or proxy stacking Set-Cookie/CSP produces - by throwing
+ // HttpException. That is a SIBLING of HttpClientException, not a subclass, so it escaped both of
+ // fetchJson's catches and left fromQuestDB throwing a type its own javadoc does not name, past
+ // every caller's catch (OidcAuthException) degrade handler.
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (SETTINGS_PATH.equals(path)) {
+ StringBuilder padding = new StringBuilder();
+ for (int i = 0; i < 5000; i++) {
+ padding.append('A');
+ }
+ return MockOidcServer.raw("HTTP/1.1 200 OK\r\n"
+ + "X-Pad: " + padding + "\r\n"
+ + "Content-Length: 0\r\n\r\n");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-X", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ try {
+ OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()).close();
+ Assert.fail("an unparseable response head must not gate discovery open");
+ } catch (OidcAuthException expected) {
+ // the documented type; an HttpException escaping here is the regression
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testMalformedResponseHeadDuringPollingIsTransient() throws Exception {
+ assertMemoryLeak(() -> {
+ // The same unparseable head on the TOKEN endpoint, mid-poll. Escaping as HttpException it missed
+ // postForm's catch and the client.disconnect() with it, so the cached keep-alive connection kept a
+ // half-read response for the next poll to parse as its own; it also missed pollForToken's
+ // classification, aborting the whole interactive sign-in on a condition the same loop rides out
+ // when it arrives as a transport error. One malformed answer must not end a sign-in.
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (tokenCalls.incrementAndGet() == 1) {
+ StringBuilder padding = new StringBuilder();
+ for (int i = 0; i < 5000; i++) {
+ padding.append('A');
+ }
+ return MockOidcServer.raw("HTTP/1.1 200 OK\r\n"
+ + "X-Pad: " + padding + "\r\n"
+ + "Content-Length: 0\r\n\r\n");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-AFTER-RECOVERY", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-AFTER-RECOVERY", auth.signIn());
+ Assert.assertTrue("the poll must have retried after the malformed head, on a clean connection",
+ tokenCalls.get() >= 2);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNonNumericStatusCodeRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile or MITM'd identity provider returns a status line whose status-code token carries an
+ // ANSI escape (the HTTP header parser copies the token verbatim apart from SP/CR/LF). A status code
+ // is bare digits, so a non-digit byte is a malformed or hostile status line: the client must reject
+ // it - never echoing its bytes (which could rewrite a terminal or forge a log line) and never
+ // trusting its leading digit as a 2xx success gate
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "}}");
+ }
+ // status code "2[m00": an ANSI reset spliced into the token. The leading '2' would pass a
+ // first-char success check, but the non-digit bytes must make the client reject the response
+ return MockOidcServer.raw("HTTP/1.1 2\u001b[m00 OK\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Content-Length: 2\r\n"
+ + "\r\n"
+ + "{}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ auth.signIn();
+ Assert.fail("expected a malformed status code to be rejected");
+ } catch (OidcAuthException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("malformed HTTP status code"));
+ Assert.assertFalse("raw ESC must not leak into the message: " + msg, msg.indexOf('\u001b') >= 0);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNonNumericStatusCodeRejectedDuringPolling() throws Exception {
+ assertMemoryLeak(() -> {
+ // the malformed-status guard must also fire on the token-poll path, where readResponse handles the
+ // POSTs that carry the device code on every poll (testNonNumericStatusCodeRejected covers the
+ // device-authorization POST). The device step succeeds, then the token endpoint returns a status
+ // line whose status-code token splices in an ANSI escape; the client must reject it - never echoing
+ // its bytes, never trusting its leading '2' as a 2xx success gate
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.raw("HTTP/1.1 2\u001b[m00 OK\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Content-Length: 2\r\n"
+ + "\r\n"
+ + "{}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "malformed HTTP status code",
+ "expected a malformed status code on the poll path to be rejected");
+ String msg = e.getMessage();
+ Assert.assertFalse("raw ESC must not leak into the message: " + msg, msg.indexOf('\u001b') >= 0);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryDefaultsScopeToOpenid() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicReference serverRef = new AtomicReference<>();
+ AtomicReference deviceBody = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ // settings advertise no scope, so the client must default to "openid"
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "}}");
+ }
+ if (DEVICE_PATH.equals(path)) {
+ deviceBody.set(body);
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-SCOPE", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.assertEquals("ACCESS-SCOPE", auth.signIn());
+ Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid"));
+ Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("groups"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryRejectsMalformedStatusWithoutEchoingIt() throws Exception {
+ assertMemoryLeak(() -> {
+ // The header parser copies the status-line token verbatim apart from SP/CR/LF, so a non-digit
+ // byte means a malformed or hostile status line. It must not be read as a 2xx by its leading
+ // digit, and none of it may reach the exception message, which lands in logs and terminals.
+ // A short all-digit status is malformed too, for the same "leading digit is not the class"
+ // reason.
+ // A COMPLETE, otherwise-valid settings body, so the only thing standing between this response
+ // and a working instance is the status gate. A partial body would fail later on a missing key
+ // and prove nothing about the status.
+ for (String statusToken : new String[]{"2\u001b[31m0", "2"}) {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, requestBody) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ String settings = settingsJson(true, true,
+ server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH));
+ return MockOidcServer.raw(
+ "HTTP/1.1 " + statusToken + " OK\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Content-Length: " + settings.length() + "\r\n\r\n"
+ + settings);
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-X", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ OidcAuthException e = assertOidcFails(
+ () -> OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()),
+ "malformed HTTP status code",
+ "a malformed status [" + statusToken + "] must not gate discovery open");
+ Assert.assertFalse("the raw status must not be echoed: " + e.getMessage(),
+ e.getMessage().indexOf('\u001b') >= 0);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSettingsUnderErrorStatusNotTrustedAsConfig() throws Exception {
+ assertMemoryLeak(() -> {
+ // /settings was parsed without looking at the status, so a body carrying the right keys was
+ // read as configuration whatever the response claimed to be. A 500 is not a settings document:
+ // an error envelope, a proxy's branded page or a captive portal could supply the endpoints the
+ // user then signs in against, and the refresh token is POSTed to. The status gate must refuse
+ // it before the body is parsed at all.
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(500,
+ settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH)));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-X", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ OidcAuthException e = assertOidcFails(
+ () -> OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()),
+ "did not return its settings",
+ "a 500 /settings body must not be trusted as OIDC configuration");
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpStatus=500"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testWellKnownUnderErrorStatusNotTrustedAsDiscoveryDoc() throws Exception {
+ assertMemoryLeak(() -> {
+ // The same hole on the .well-known fallback, which is what a pinned issuer falls back to when
+ // /settings advertises no device endpoint. A 404 body is not a discovery document - a
+ // tenant-not-found stub is exactly the shape that reaches this path - so it must not be able to
+ // name the token and device endpoints.
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null));
+ }
+ if (WELL_KNOWN_PATH.equals(path)) {
+ return MockOidcServer.json(404,
+ wellKnownJson(server.httpUrl(DEVICE_PATH), server.httpUrl(TOKEN_PATH), server.httpUrl("")));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-X", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(
+ server.httpUrl(""), insecure().issuer(server.httpUrl("")))) {
+ Assert.fail("a 404 .well-known body must not be trusted as a discovery document");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("did not return an OIDC discovery document"));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpStatus=404"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryIgnoresArrayWrappedConfig() throws Exception {
+ assertMemoryLeak(() -> {
+ // a tampered /settings wraps the config object in an ARRAY - {"config":[{...}]} - so the config
+ // keys sit inside an array element rather than the trusted top-level "config" object. The parser
+ // must not surface an array element's object as config (mirroring FileTokenStore's array
+ // rejection), so OIDC reads as disabled and fromQuestDB fails rather than trusting wrapped config.
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"config\":[{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.scope\":\"openid\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "}]}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-X", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ assertOidcFails(() -> OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()),
+ "OIDC is not enabled", "array-wrapped config must not be trusted as OIDC config");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryIgnoresPreferencesKeys() throws Exception {
+ assertMemoryLeak(() -> {
+ // the unprivileged-writable "preferences" object tries to poison discovery (flip enabled
+ // off, flip groups-in-token, inject scope); only the trusted top-level "config" object
+ // must feed discovery
+ AtomicReference serverRef = new AtomicReference<>();
+ AtomicReference deviceBody = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.scope\":\"openid\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "},\"preferences.version\":0,\"preferences\":{"
+ + "\"acl.oidc.enabled\":false,"
+ + "\"acl.oidc.groups.encoded.in.token\":true,"
+ + "\"acl.oidc.scope\":\"INJECTED\""
+ + "}}");
+ }
+ if (DEVICE_PATH.equals(path)) {
+ deviceBody.set(body);
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-TRUSTED", "ID-TRUSTED", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ // enabled stayed true (no DoS), groups-in-token stayed false (access token served),
+ // scope stayed "openid" (no injection)
+ Assert.assertEquals("ACCESS-TRUSTED", auth.signIn());
+ Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid"));
+ Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("INJECTED"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryReadsAudience() throws Exception {
+ assertMemoryLeak(() -> {
+ // the audience advertised by /settings (acl.oidc.audience) must be url-encoded into the device
+ // authorization request, matching the Python client
+ AtomicReference serverRef = new AtomicReference<>();
+ AtomicReference deviceBody = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.audience\":\"api://questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "}}");
+ }
+ if (DEVICE_PATH.equals(path)) {
+ deviceBody.set(body);
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-AUD-D", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.assertEquals("ACCESS-AUD-D", auth.signIn());
+ Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryRejectsMissingClientId() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ // OIDC enabled, endpoints advertised, but no client id
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected discovery to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("client id"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ // OIDC enabled with a client id, but no token endpoint
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected discovery to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBodyReadAbortsOnItsElapsedDeadline() throws Exception {
+ assertMemoryLeak(() -> {
+ // parseBody bounds the WHOLE body read against an untrusted identity provider: a server that
+ // keeps delivering, slowly, for longer than httpTimeoutMillis must not hold the thread. The bound
+ // sits on a hot path - getToken() runs once per ILP flush - so losing it stalls ingestion rather
+ // than failing it.
+ //
+ // Two things can end that read and they are NOT interchangeable: the per-call recv bound
+ // ("timed out reading the chunked response body") and parseBody's own elapsed deadline. Only the
+ // second one catches a peer whose every individual read SUCCEEDS while the read as a whole runs
+ // past the budget, and nothing exercised it - a socket-level test races the two bounds and pins
+ // whichever wins on the day.
+ //
+ // Driving parseBody directly removes the race. The Response below hands back an EMPTY fragment
+ // immediately and forever: every recv succeeds, so the recv bound can never fire; totalBytes
+ // never grows, so the 4 MiB cap can never fire either; and the lexer is fed nothing, so it
+ // cannot throw. The elapsed deadline is the only exit, which is exactly the line under test.
+ Method parseBody = OidcDeviceAuth.class.getDeclaredMethod(
+ "parseBody", Response.class, JsonLexer.class, JsonParser.class, int.class);
+ parseBody.setAccessible(true);
+
+ Fragment empty = new Fragment() {
+ @Override
+ public long hi() {
+ return 0;
+ }
+
+ @Override
+ public long lo() {
+ return 0;
+ }
+ };
+ AtomicInteger reads = new AtomicInteger();
+ Response alwaysReady = new Response() {
+ @Override
+ public Fragment recv() {
+ return recv(0);
+ }
+
+ @Override
+ public Fragment recv(int timeout) {
+ reads.incrementAndGet();
+ return empty;
+ }
+ };
+
+ try (JsonLexer lexer = new JsonLexer(1024, 1024)) {
+ long startMillis = System.currentTimeMillis();
+ try {
+ parseBody.invoke(null, alwaysReady, lexer, NOOP_JSON_PARSER, 200);
+ Assert.fail("a body that never ends must abort on the elapsed deadline");
+ } catch (InvocationTargetException e) {
+ Assert.assertTrue("expected the elapsed-deadline abort, got: " + e.getCause(),
+ e.getCause() instanceof HttpClientException);
+ Assert.assertEquals("timed out reading the identity provider response body",
+ e.getCause().getMessage());
+ }
+ long elapsedMillis = System.currentTimeMillis() - startMillis;
+ Assert.assertTrue("the deadline must bound the read, not merely end it eventually: "
+ + elapsedMillis + "ms", elapsedMillis < 10_000);
+ Assert.assertTrue("every read must have succeeded, or this pinned the recv bound instead",
+ reads.get() > 0);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testClearCacheWipesTheLexerDecodeBuffers() throws Exception {
+ assertMemoryLeak(() -> {
+ // The sibling below covers close(), which cannot see this: close() runs the same sweep and THEN
+ // does jsonLexer = Misc.free(jsonLexer), so the field is null by the time the reflective walk
+ // looks and the lexer is garbage either way. clearCache() deliberately keeps the lexer alive -
+ // the instance stays usable for a later signIn() - so whatever it still holds stays reachable
+ // from this object.
+ //
+ // What it holds is the token itself. The lexer ASSEMBLES every name and value in its own decode
+ // sinks before a listener ever sees one, so TokenResponseParser's copy is the second copy, not
+ // the first; wiping the parsers left the originals untouched. JsonLexer.clear() resets parse
+ // state only, and StringSink.clear() would just rewind the write position anyway.
+ //
+ // So a caller who called clearCache() to sign this process out still had the access, id and
+ // refresh tokens legible on the heap - the exact retention StringSink.wipe() was added to close.
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEVCODE-LEXER\","
+ + "\"user_code\":\"USERCODE-LEXER\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,\"interval\":1}");
+ }
+ return MockOidcServer.json(200,
+ tokenJson("ACCESS-LEXER-WIPE-ME", "ID-LEXER-WIPE-ME", "REFRESH-LEXER-WIPE-ME", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt());
+ try {
+ Assert.assertEquals("ACCESS-LEXER-WIPE-ME", auth.signIn());
+ // The decode buffers really carry a secret, or clearing them below proves nothing. The
+ // sink is reused per value, so which one survives is whichever the parse ended on plus
+ // whatever is still legible in the tail past it - the retention itself, so assert on the
+ // set rather than on one field's position in the response.
+ String before = lexerBuffers(auth);
+ boolean holdsOne = false;
+ for (String secret : new String[]{
+ "ACCESS-LEXER-WIPE-ME", "ID-LEXER-WIPE-ME", "REFRESH-LEXER-WIPE-ME"}) {
+ holdsOne |= before.contains(secret);
+ }
+ Assert.assertTrue("the lexer must hold a parsed token before the wipe, otherwise this "
+ + "test cannot fail: " + before, holdsOne);
+
+ auth.clearCache();
+
+ String buffers = lexerBuffers(auth);
+ for (String secret : new String[]{
+ "ACCESS-LEXER-WIPE-ME", "ID-LEXER-WIPE-ME", "REFRESH-LEXER-WIPE-ME"}) {
+ Assert.assertFalse("clearCache() left \"" + secret + "\" legible in the lexer's "
+ + "decode buffers", buffers.contains(secret));
+ }
+ // and nothing else on the instance kept a copy either
+ assertHoldsNowhere(auth, "ACCESS-LEXER-WIPE-ME");
+ assertHoldsNowhere(auth, "REFRESH-LEXER-WIPE-ME");
+ } finally {
+ auth.close();
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testCloseWipesCredentialState() throws Exception {
+ assertMemoryLeak(() -> {
+ // close() disables every token operation, so nothing it holds can be needed again - yet the
+ // instance went on holding all of it: the served token and refresh token in their String fields,
+ // and the raw grant in the sinks that carried it. formSink keeps the last request body, which on
+ // the refresh path is literally "refresh_token="; the two response parsers keep every
+ // field of the last response, device code included. All are reused, and clear() only rewinds the
+ // write position, so a long secret followed by a short write stays legible in the tail.
+ //
+ // The walk below is deliberately reflective and generic rather than a list of field names: a sink
+ // added to this class or to either parser later is covered without anyone remembering to extend
+ // the test.
+ AtomicInteger deviceCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEVCODE-WIPE-ME\","
+ + "\"user_code\":\"USERCODE-WIPE-ME\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,\"interval\":1}");
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ return MockOidcServer.json(200,
+ tokenJson("ACCESS-REFRESHED-WIPE-ME", "ID-REFRESHED-WIPE-ME", "REFRESH-2-WIPE-ME", 3600));
+ }
+ return MockOidcServer.json(200,
+ tokenJson("ACCESS-WIPE-ME", "ID-WIPE-ME", "REFRESH-1-WIPE-ME", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt());
+ try {
+ Assert.assertEquals("ACCESS-WIPE-ME", auth.signIn());
+ expireCachedToken(auth);
+ // spend the refresh token too, so it passes through formSink as a request parameter
+ Assert.assertEquals("ACCESS-REFRESHED-WIPE-ME", auth.getToken());
+ Assert.assertEquals(1, deviceCalls.get());
+ // the state is genuinely there before the close - otherwise the sweep below proves nothing
+ assertHoldsSomewhere(auth, "REFRESH-2-WIPE-ME");
+ } finally {
+ auth.close();
+ }
+
+ Assert.assertNull("the served access token must not survive close()",
+ readField(auth, "accessToken"));
+ Assert.assertNull("the id token must not survive close()", readField(auth, "idToken"));
+ Assert.assertNull("the refresh token must not survive close()", readField(auth, "refreshToken"));
+ Assert.assertNull("the last-persisted refresh token must not survive close()",
+ readField(auth, "lastPersistedRefreshToken"));
+ for (String secret : new String[]{
+ "ACCESS-WIPE-ME", "ID-WIPE-ME", "REFRESH-1-WIPE-ME",
+ "ACCESS-REFRESHED-WIPE-ME", "ID-REFRESHED-WIPE-ME", "REFRESH-2-WIPE-ME",
+ "DEVCODE-WIPE-ME", "USERCODE-WIPE-ME"}) {
+ assertHoldsNowhere(auth, secret);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Exception {
+ // discoverSettings allocates a JSON lexer (NATIVE_TEXT_PARSER_RSS) and an HTTP client (NATIVE_DEFAULT
+ // buffers) and frees both in a finally; a transport failure during discovery must not leak either.
+ // assertMemoryLeak covers EVERY tag - its LeakCheck asserts per-tag equality across the whole
+ // MemoryTag range, then total equality - so it is the outer guard here rather than something to work
+ // around. The two explicit tag assertions stay because they name the buffer that leaked, which a
+ // blanket "total native memory" mismatch does not.
+ assertMemoryLeak(() -> {
+ int deadPort;
+ try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ deadPort = probe.getLocalPort();
+ } // closed now - nothing listens on deadPort
+ long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS);
+ long clientMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB("http://127.0.0.1:" + deadPort, insecure())) {
+ Assert.fail("expected discovery to fail against a dead port");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not reach the QuestDB server"));
+ }
+ Assert.assertEquals("the discovery JSON lexer native buffer leaked",
+ parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS));
+ Assert.assertEquals("the discovery HTTP client native buffers leaked",
+ clientMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT));
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDuplicateJsonKeysDoNotConcatenate() throws Exception {
+ assertMemoryLeak(() -> {
+ // a buggy/hostile IdP repeats a key; the parser must keep the last value, not concatenate it
+ // onto the first (e.g. AAABBB), which would corrupt the served token and the device code
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV-CODE\","
+ + "\"user_code\":\"WRONG\",\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, "{\"token_type\":\"Bearer\",\"expires_in\":3600,"
+ + "\"access_token\":\"AAA\",\"access_token\":\"ACCESS-LAST\"}");
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ // the duplicate access_token resolves to the last value, not "AAAACCESS-LAST"
+ Assert.assertEquals("ACCESS-LAST", auth.signIn());
+ // the duplicate user_code resolves to the last value, not "WRONGWDJB-MJHT"
+ Assert.assertEquals("WDJB-MJHT", shown.get().getUserCode());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testEndpointParseRejectsDisplayUnsafeUrl() {
+ // a url carrying a display-unsafe character is rejected, and the rejection message itself must carry
+ // none: otherwise a tampered /settings or discovery endpoint url could reorder, hide or forge the
+ // log line / exception text it lands in. The control-char scan alone does not catch these higher
+ // code points (bidi, zero-width, BOM, supplementary-plane tag chars), the last scanned per code point
+ String[] unsafe = {
+ String.valueOf((char) 0x202E), // right-to-left override
+ String.valueOf((char) 0x200B), // zero-width space
+ String.valueOf((char) 0xFEFF), // BOM / zero-width no-break space
+ new String(Character.toChars(0xE0001)) // U+E0001 LANGUAGE TAG (supplementary-plane format char)
+ };
+ for (int i = 0; i < unsafe.length; i++) {
+ String marker = unsafe[i];
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://idp.example/dev" + marker + "ice")
+ .tokenEndpoint("https://idp.example/t")
+ .build()
+ ) {
+ Assert.fail("expected the display-unsafe url to be rejected [index=" + i + "]");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("illegal character"));
+ // the raw unsafe character must not survive into the message
+ assertNoUnsafeDisplayChars(e.getMessage());
+ }
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testEndpointParseAcceptsUppercaseScheme() throws Exception {
+ assertMemoryLeak(() -> {
+ // RFC 3986 schemes are case-insensitive, so HTTPS/Http must build - matching BrowserLauncher's
+ // case-insensitive scheme allowlist. (Endpoint.parse lower-cases only ASCII, so a homoglyph scheme
+ // is still rejected as "expected http or https".)
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("HTTPS://idp.example/device")
+ .tokenEndpoint("Https://idp.example/token")
+ .build()
+ ) {
+ // accepted: build() did not reject the mixed-case https scheme
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testEndpointParseRejectsMalformedUrls() {
+ // Endpoint.parse rejects malformed endpoint URLs at build time
+ assertBuildFails("ftp://idp/d", "https://idp/t", "expected http or https");
+ assertBuildFails("idp/d", "https://idp/t", "expected a scheme");
+ assertBuildFails("https://idp/d", "https://idp:notaport/t", "could not parse the port");
+ assertBuildFails("https:///d", "https://idp/t", "the host is empty");
+ assertBuildFails("https://[::1]:9000/d", "https://idp/t", "IPv6 literal hosts are not supported");
+ // userinfo (user@host or user:pass@host) is unsupported: the HTTP layer would connect to the literal
+ // "user@host", so reject it rather than mis-resolve it or report a misleading port-parse error
+ assertBuildFails("https://user@idp/d", "https://idp/t", "userinfo");
+ assertBuildFails("https://idp/d", "https://user:pass@idp/t", "userinfo");
+ // an out-of-range port (0, negative, or above 65535) is rejected rather than passed to the transport
+ assertBuildFails("https://idp:99999/d", "https://idp/t", "between 1 and 65535");
+ assertBuildFails("https://idp:0/d", "https://idp/t", "between 1 and 65535");
+ assertBuildFails("https://idp:-1/d", "https://idp/t", "between 1 and 65535");
+ assertBuildFails("https://idp/d", "https://idp:70000/t", "between 1 and 65535");
+ // a leading '+' on the port is rejected: Integer.parseInt would read ":+443" as 443, but a real
+ // authority port is bare digits (a leading '-' is already caught by the range check above)
+ assertBuildFails("https://idp:+443/d", "https://idp/t", "could not parse the port");
+ // a host carrying control characters or whitespace (e.g. a smuggled CR/LF that would inject into the
+ // outbound Host header) is rejected rather than passed verbatim to the transport
+ assertBuildFails("https://ho\r\nst/d", "https://idp/t", "illegal character");
+ assertBuildFails("https://h\tst/d", "https://idp/t", "illegal character");
+ assertBuildFails("https://h st/d", "https://idp/t", "illegal character");
+ assertBuildFails("https://idp/d", "https://e\nvil/t", "illegal character");
+ // a control character or whitespace in the path or query is rejected too: postForm sends the path
+ // verbatim on the request line, so a smuggled CR/LF there would inject a header / smuggle a request
+ assertBuildFails("https://idp/devic\r\ne", "https://idp/t", "illegal character");
+ assertBuildFails("https://idp/d", "https://idp/toke\r\nX-Injected:1", "illegal character");
+ assertBuildFails("https://idp/d", "https://idp/t?a=b\nc", "illegal character");
+ // a fragment (#...) is rejected: pathOnly() strips it before the issuer-path pin while postForm sends
+ // endpoint.path verbatim on the wire, so folding a "#/../other/token" past the '#' would let a lenient
+ // server that normalizes '..' resolve the request-target to a path the pin never validated. Fail closed
+ assertBuildFails("https://idp/realms/acme#/../other/device", "https://idp/realms/acme/token", "fragment");
+ assertBuildFails("https://idp/d", "https://idp/realms/acme#/../other/token", "fragment");
+ assertBuildFails("https://idp/d#", "https://idp/t", "fragment");
+ // a query (?...) is rejected for the same pin-bypass reason: pathOnly() strips it before the issuer-path
+ // pin while postForm sends endpoint.path - query included - verbatim, so a "?..." the pin never validated
+ // would still reach the wire. An OIDC device/token endpoint carries its parameters in the request body,
+ // never the url query, so fail closed (the user-facing verification url may carry one, but it is parsed
+ // by BrowserLauncher, not Endpoint.parse)
+ assertBuildFails("https://idp/realms/acme/device?x=/../other", "https://idp/realms/acme/token", "query");
+ assertBuildFails("https://idp/d", "https://idp/realms/acme/token?client_id=evil", "query");
+ assertBuildFails("https://idp/d?a=b", "https://idp/t", "query");
+ // a non-ASCII host is rejected: it would not resolve (the HTTP layer sends the host to the OS resolver
+ // as raw UTF-8, no IDNA), and equalsIgnoreCase folds several non-ASCII letters onto ASCII (U+0130 -> i,
+ // U+212A -> k, ...), so a homoglyph host could otherwise pass the origin pin against a pinned issuer
+ assertBuildFails("https://\u0130dp/d", "https://idp/t", "non-ASCII"); // U+0130, folds to i
+ assertBuildFails("https://idp/d", "https://\u212Aelvin/t", "non-ASCII"); // U+212A Kelvin, folds to k
+ // a backslash in the host is rejected: the WHATWG URL spec folds '\' to '/', so a lenient consumer
+ // could re-split good.com\.evil.com into a different authority
+ assertBuildFails("https://good.com\\.evil.com/d", "https://idp/t", "backslash");
+ }
+
+ @Test(timeout = 30_000)
+ public void testEscapedDeviceCodeRoundTripsDecoded() throws Exception {
+ assertMemoryLeak(() -> {
+ // an IdP that escapes a character in device_code (here a slash) must have it decoded before the
+ // client posts it back, otherwise the polled device_code never matches what the IdP issued
+ AtomicReference pollBody = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\\/CODE\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ pollBody.set(body);
+ return MockOidcServer.json(200, tokenJson("ACCESS-DC", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-DC", auth.signIn());
+ // device_code was "DEV\/CODE" in JSON; decoded to "DEV/CODE" and url-encoded as DEV%2FCODE
+ Assert.assertTrue(pollBody.get(), pollBody.get().contains("device_code=DEV%2FCODE"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testEscapedErrorDescriptionDecoded() throws Exception {
+ assertMemoryLeak(() -> {
+ // an error_description with JSON-escaped characters must be decoded in the exception message,
+ // not shown with literal backslashes
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"it\\\"s a \\/ test\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "it\"s a / test");
+ Assert.assertEquals("access_denied", e.getOauthError());
+ // the escapes are decoded, not shown literally
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("\\/"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testEscapedVerificationUrlIsUnescapedForDisplay() throws Exception {
+ assertMemoryLeak(() -> {
+ // some identity providers JSON-escape forward slashes (PHP json_encode does by default), e.g.
+ // "https:\/\/...". The challenge shown to the user must decode the escapes, not display literal
+ // backslashes that break the link
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV-CODE\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https:\\/\\/verify.example\\/device\","
+ + "\"verification_uri_complete\":\"https:\\/\\/verify.example\\/device?user_code=WDJB-MJHT\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-ESC", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-ESC", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri());
+ Assert.assertEquals("https://verify.example/device?user_code=WDJB-MJHT", challenge.getVerificationUriComplete());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbDiscoversDeviceEndpointFromIssuer() throws Exception {
+ assertMemoryLeak(() -> {
+ // the server advertises a token endpoint but not the device authorization endpoint (today's
+ // servers); pinning the issuer lets the client discover the device endpoint from the issuer's
+ // .well-known/openid-configuration document and complete the flow
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null));
+ }
+ if (WELL_KNOWN_PATH.equals(path)) {
+ return MockOidcServer.json(200, wellKnownJson(server.httpUrl(DEVICE_PATH), server.httpUrl(TOKEN_PATH), server.httpUrl("")));
+ }
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-WK", "ID-WK", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ // the issuer is the mock itself, which also serves the .well-known document and the IdP endpoints
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("")))) {
+ // settings advertise groups.encoded.in.token=true, so signIn() returns the id token
+ Assert.assertEquals("ID-WK", auth.signIn());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbDiscoveryDocMissingDeviceEndpointRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // discovery runs against the pinned issuer, but the discovery document does not advertise a
+ // device authorization endpoint (the identity provider lacks the device grant); fail clearly
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null));
+ }
+ // a discovery document with a token endpoint and issuer but no device_authorization_endpoint
+ return MockOidcServer.json(200, "{"
+ + "\"issuer\":\"" + server.httpUrl("") + "\","
+ + "\"token_endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\""
+ + "}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("")))) {
+ Assert.fail("expected discovery to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("device_authorization_endpoint"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbDiscoveryRunsFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH)));
+ }
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-D", "ID-D", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ // discovery advertises groups.encoded.in.token=true, so signIn() must return the id token
+ Assert.assertEquals("ID-D", auth.signIn());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbIssuerPinAcceptsOffOriginDiscoveredEndpoints() throws Exception {
+ assertMemoryLeak(() -> {
+ // The Google case: the pinned issuer hosts its discovery document on one origin but serves its
+ // token and device endpoints on another. /settings advertises neither endpoint, so both are
+ // discovered from the issuer's own .well-known (a trusted, out-of-band source) and must be accepted
+ // wherever the issuer hosts them, NOT origin-pinned to the issuer. An endpoint the untrusted
+ // /settings advertised IS still origin-pinned - see testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint.
+ AtomicReference idpRef = new AtomicReference<>();
+ AtomicReference issuerRef = new AtomicReference<>();
+ // the IdP endpoint host: a different origin (port) than the issuer below
+ MockOidcServer.Handler idpHandler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OFF", null, null, 3600));
+ };
+ try (MockOidcServer idp = new MockOidcServer(idpHandler)) {
+ idpRef.set(idp);
+ // the QuestDB server doubles as the pinned issuer: it serves /settings (advertising neither
+ // endpoint) and the .well-known document, which points the device/token endpoints at the
+ // off-origin idp
+ MockOidcServer.Handler issuerHandler = (method, path, body) -> {
+ MockOidcServer endpointHost = idpRef.get();
+ MockOidcServer iss = issuerRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.scope\":\"openid\""
+ + "}}");
+ }
+ if (WELL_KNOWN_PATH.equals(path)) {
+ return MockOidcServer.json(200, wellKnownJson(
+ endpointHost.httpUrl(DEVICE_PATH), endpointHost.httpUrl(TOKEN_PATH), iss.httpUrl("")));
+ }
+ return MockOidcServer.json(404, "{}");
+ };
+ try (MockOidcServer issuer = new MockOidcServer(issuerHandler)) {
+ issuerRef.set(issuer);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(issuer.httpUrl(""), insecure().issuer(issuer.httpUrl("")))) {
+ // the off-origin discovered endpoints are accepted; the device flow completes against them
+ Assert.assertEquals("ACCESS-OFF", auth.signIn());
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws Exception {
+ assertMemoryLeak(() -> {
+ // the server advertises both endpoints directly, but they do not belong to the pinned issuer
+ // origin; the issuer pin must reject them rather than route credentials off the trusted issuer
+ // (this is the protection against a compromised-but-reachable server redirecting the sign-in)
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH)));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer("https://idp.attacker.example"))) {
+ Assert.fail("expected the issuer pin to reject the off-origin endpoints");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("is not on the pinned identity-provider origin"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbRejectsCrlfInjectedAdvertisedEndpoint() throws Exception {
+ assertMemoryLeak(() -> {
+ // a tampered /settings advertises a token endpoint whose path carries a JSON-escaped CR/LF; the
+ // lexer decodes it to real control characters, and Endpoint.parse must reject it rather than let
+ // it inject into the outbound request line (header smuggling against the identity provider)
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ String crlf = jsonUnicodeEscape(0x0d) + jsonUnicodeEscape(0x0a);
+ String injectedToken = server.httpUrl(TOKEN_PATH) + crlf + "X-Injected:1";
+ return MockOidcServer.json(200, settingsJson(true, true, injectedToken, server.httpUrl(DEVICE_PATH)));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected the CR/LF-injected token endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("illegal character"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbRejectsInsecureServerUrl() {
+ // the default-secure fromQuestDB overload must reject an http:// QuestDB server url (the discovery
+ // response and the sign-in it bootstraps would travel in cleartext) unless insecure transport is
+ // explicitly opted in
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB("http://questdb.example:9000")) {
+ Assert.fail("expected an http server url to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("QuestDB server url"));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http"));
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbRejectsMissingDeviceEndpoint() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ // OIDC enabled, but no device authorization endpoint advertised (an older server)
+ return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected discovery to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFromQuestDbRejectsOidcDisabled() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(200, settingsJson(false, false, serverRef.get().httpUrl(TOKEN_PATH), null));
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected discovery to fail");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("OIDC is not enabled"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // the cached token expires and the refresh hits a transient non-JSON body (e.g. a gateway
+ // 502 HTML page). The client must fall back to the interactive flow, not propagate the parse
+ // failure out of signIn()
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger deviceCodeGrants = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // a transient gateway error page instead of a token JSON
+ return MockOidcServer.json(502, "502 Bad Gateway");
+ }
+ if (deviceCodeGrants.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // the cached token is expired and the refresh body is garbled, so the client must re-run
+ // the interactive flow instead of throwing the parse error
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception {
+ assertMemoryLeak(() -> {
+ // an interactive signIn() is parked polling (authorization_pending), holding the instance
+ // lock for the whole device-code lifetime. A flush-path getToken() on another thread
+ // must NOT block behind it - it must fail fast, so a Sender flush is never stalled by a
+ // concurrent sign-in. (With the old synchronized model it blocked until the code expired.)
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 10));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}");
+ };
+ CountDownLatch polling = new CountDownLatch(1);
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, challenge -> polling.countDown())) {
+ Thread signIn = new Thread(() -> {
+ try {
+ auth.signIn();
+ } catch (Throwable ignore) {
+ // expected: cancelled by close() at the end of the test
+ }
+ }, "oidc-sign-in");
+ signIn.setDaemon(true);
+ signIn.start();
+ try {
+ // wait until the interactive flow has prompted and is polling (it holds the lock now)
+ Assert.assertTrue("the sign-in did not reach the polling stage", polling.await(10, TimeUnit.SECONDS));
+ // getToken() must return control promptly (here: throw), NOT block ~10s until
+ // the device code expires and signIn() releases the lock
+ long startNanos = System.nanoTime();
+ OidcAuthException e = assertOidcFails(auth::getToken, "in progress",
+ "expected getToken() to fail fast while a sign-in is in progress");
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight sign-in",
+ elapsedMillis < 2_000);
+ } finally {
+ auth.close(); // cancel the in-flight sign-in
+ signIn.join(10_000); // let the daemon thread unwind before the leak check
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenSucceedsWhenCallingThreadIsInterrupted() throws Exception {
+ assertMemoryLeak(() -> {
+ // getToken()'s uncontended lock acquire must NOT fail merely because the calling thread carries a
+ // set interrupt flag. An ILP producer on a pooled/managed thread commonly does (interrupt is the
+ // standard cancellation signal), and the old timed tryLock threw InterruptedException even on a FREE
+ // lock and then re-armed the flag, so every getToken() on that thread failed with a valid token
+ // sitting in the cache. The untimed fast-path acquire fixes it.
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn()); // seed a valid cached token
+
+ Thread.currentThread().interrupt(); // the calling (producer) thread carries a pending interrupt
+ try {
+ // uncontended lock, valid cached token: getToken() must return it, not throw on the interrupt
+ Assert.assertEquals("ACCESS-1", auth.getToken());
+ // and it must not silently swallow the caller's interrupt (the untimed acquire preserves it)
+ Assert.assertTrue("getToken() must not clear the caller's interrupt flag",
+ Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted(); // clear so the flag does not leak into later tests sharing this fork
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Exception {
+ assertMemoryLeak(() -> {
+ // When another thread's SILENT REFRESH (not an interactive sign-in) holds the lock, a second
+ // getToken() must WAIT for that bounded refresh and then serve the freshly refreshed token - NOT
+ // fail fast. Failing fast would make every concurrent caller sharing one OidcDeviceAuth (the
+ // documented shared-provider pattern) spuriously throw on each token refresh. The token endpoint
+ // blocks the refresh response until the test releases it, pinning the lock on the refresher thread
+ // while the second caller waits for it. (This is the fix for the old fail-fast-on-any-contention
+ // behaviour: the HttpTokenProvider contract permits a brief wait behind a silent refresh.)
+ CountDownLatch refreshInFlight = new CountDownLatch(1);
+ CountDownLatch releaseRefresh = new CountDownLatch(1);
+ AtomicReference handlerError = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ refreshInFlight.countDown();
+ try {
+ if (!releaseRefresh.await(30, TimeUnit.SECONDS)) {
+ // on a MockOidcServer thread: JUnit would swallow an Assert.fail here, so record it
+ // and let the main thread assert on it at the end
+ handlerError.set("the test never released the refresh within 30s");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); // initial device_code grant
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ auth.signIn(); // sign in once: caches ACCESS-1 and a refresh token
+ expireCachedToken(auth); // so the refresher thread's getToken() takes the refresh path
+ // The refresher is not scenery: it is the thread that holds the lock, performs the refresh
+ // and produces ACCESS-2. Swallowing its failure let the test pass on a run where the refresh
+ // never happened - the waiter would simply refresh for itself and still see ACCESS-2, so
+ // every assertion below still held while the contention this test exists for never occurred.
+ AtomicReference refresherError = new AtomicReference<>();
+ AtomicReference refresherResult = new AtomicReference<>();
+ Thread refresher = new Thread(() -> {
+ try {
+ refresherResult.set(auth.getToken());
+ } catch (Throwable t) {
+ refresherError.set(t);
+ }
+ }, "oidc-silent-refresh");
+ refresher.setDaemon(true);
+ refresher.start();
+ Assert.assertTrue("the silent refresh did not start", refreshInFlight.await(10, TimeUnit.SECONDS));
+
+ // a refresh holds the lock now; a second getToken() must WAIT for it, not fail fast
+ AtomicReference waiterResult = new AtomicReference<>();
+ AtomicReference waiterError = new AtomicReference<>();
+ Thread waiter = new Thread(() -> {
+ try {
+ waiterResult.set(auth.getToken());
+ } catch (Throwable t) {
+ waiterError.set(t);
+ }
+ }, "oidc-getToken-waiter");
+ waiter.setDaemon(true);
+ waiter.start();
+ // Wait until the waiter is genuinely INSIDE getToken(), read off its own stack. The latch this
+ // replaced counted down as the first statement of the thread body - BEFORE the call it claimed
+ // to gate - so it proved only that the thread had been scheduled, and every "still blocked"
+ // assertion below rested on the sleep that follows instead.
+ Assert.assertTrue("the waiter never entered getToken()", awaitInside(waiter, "getToken", 10_000));
+ try {
+ // give the waiter time to (wrongly) fail fast if it were going to; while the refresh is held
+ // it must instead still be blocked INSIDE getToken() - a fail-fast throw would have left that
+ // frame (and finished the thread)
+ Thread.sleep(500);
+ Assert.assertTrue("getToken() must still be blocked behind the peer's refresh, not finished",
+ waiter.isAlive());
+ Assert.assertTrue("getToken() must still be inside the call, waiting out the peer's refresh",
+ isInside(waiter, "getToken"));
+ Assert.assertNull("getToken() must not fail fast behind a silent refresh, but threw: " + waiterError.get(),
+ waiterError.get());
+ Assert.assertNull("getToken() must wait, not return, while the peer's refresh is still in flight",
+ waiterResult.get());
+ } finally {
+ releaseRefresh.countDown();
+ waiter.join(10_000);
+ refresher.join(10_000);
+ }
+ // once the peer's refresh completed and released the lock, the waiter served the fresh token
+ Assert.assertNull("the refresher itself failed, so the wait was never behind a real refresh: "
+ + refresherError.get(), refresherError.get());
+ Assert.assertEquals("the refresher must have completed the refresh it was holding the lock for",
+ "ACCESS-2", refresherResult.get());
+ Assert.assertNull("getToken() must not throw when it waits out a peer's refresh: " + waiterError.get(),
+ waiterError.get());
+ Assert.assertEquals("getToken() must serve the freshly refreshed token after waiting", "ACCESS-2", waiterResult.get());
+ Assert.assertNull("the mock server handler must not have reported an error", handlerError.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenRefreshesWhenServedKindIsNullButRefreshTokenExists() throws Exception {
+ assertMemoryLeak(() -> {
+ // groupsInToken=true, but the device-code grant returns an access_token + refresh_token and NO
+ // id_token: signIn() rejects that grant (the served id_token is missing) yet leaves the refresh token
+ // in memory. A later getToken() must then attempt a silent refresh - which here yields the id_token -
+ // rather than give up with "no token has been obtained yet". M5: the refresh is no longer foreclosed
+ // just because the served-kind token is currently null.
+ AtomicInteger refreshCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ refreshCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", "REFRESH-2", 3600)); // now with id_token
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600)); // initial grant: no id_token
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { // groupsInToken=true
+ assertOidcFails(auth::signIn, "no id_token",
+ "signIn() must reject a grant with no id_token when groups are encoded in the token");
+ // the partial grant left a refresh token in memory; getToken() must refresh to obtain the id_token
+ Assert.assertEquals("ID-2", auth.getToken());
+ Assert.assertEquals("getToken() must have performed a silent refresh", 1, refreshCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGetTokenRefreshesWithoutPrompting() throws Exception {
+ assertMemoryLeak(() -> {
+ // getToken() returns the cached token, silently refreshes it when it expires, and never
+ // prompts; if it cannot produce a token without an interactive sign-in, it throws
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger promptCalls = new AtomicInteger();
+ AtomicBoolean refreshOk = new AtomicBoolean(true);
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ return refreshOk.get()
+ ? MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 1))
+ : MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) {
+ // before any sign-in, getToken() must not prompt - it throws
+ assertOidcFails(auth::getToken, "no token", "expected getToken() to fail before sign-in");
+ // sign in once interactively
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // the cached token is expired, so getToken() refreshes silently
+ Assert.assertEquals("ACCESS-2", auth.getToken());
+ // now make the refresh fail; getToken() must throw, not start the device flow
+ refreshOk.set(false);
+ expireCachedToken(auth);
+ assertOidcFails(auth::getToken, "interactive sign-in",
+ "expected getToken() to fail when the refresh is rejected");
+ // the device flow ran exactly once (the initial signIn), and the user was prompted once
+ Assert.assertEquals(1, deviceCalls.get());
+ Assert.assertEquals(1, promptCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBlankServedTokenFromWireIsNotServed() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile or broken IdP returns a whitespace-only access token on the grant: it is non-empty and
+ // passes the control/non-ASCII char check vacuously (space is 0x20), but must NOT be cached and
+ // served as a blank "Bearer " header (which only draws a 401). storeTokens folds a blank served
+ // token to absent, so signIn() fails with the actionable "no access_token" rather than serving " "
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson(" ", null, "REFRESH-1", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "no access_token",
+ "expected signIn() to reject a blank served token from the wire");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testBlankTokenFromRefreshFallsBackToInteractiveFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // a non-conformant IdP answers a SILENT REFRESH with a 2xx carrying a whitespace-only access token.
+ // The refresh gate (hasRequiredToken) must treat it as absent with the same Chars.isBlank contract
+ // storeTokens uses, so tryRefresh() reports failure and signIn() falls back to the interactive device
+ // flow - rather than caching a token storeTokens then nulls, which would make signIn() throw "no
+ // access_token" while a fresh interactive sign-in was still possible.
+ AtomicInteger deviceCodePolls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // blank served token on refresh: the gate must fall back, not cache-and-serve it
+ return MockOidcServer.json(200, tokenJson(" ", null, null, 3600));
+ }
+ // the device-code grant: the first poll mints the initial short-lived token; the second is the
+ // interactive fallback after the blank refresh and mints a fresh, usable one
+ return deviceCodePolls.incrementAndGet() == 1
+ ? MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 60))
+ : MockOidcServer.json(200, tokenJson("ACCESS-FALLBACK", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth); // force the silent-refresh path on the next sign-in
+ // with the blank-refresh gate fixed, signIn() falls back to the device flow instead of throwing
+ Assert.assertEquals("ACCESS-FALLBACK", auth.signIn());
+ Assert.assertEquals("the interactive device flow must run again as the fallback",
+ 2, deviceCodePolls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGroupsInTokenButNoIdTokenFails() throws Exception {
+ assertMemoryLeak(() -> {
+ // groups encoded in token, but the IdP returns only an access token on the initial grant
+ // (e.g. the requested scope omitted openid); signIn() must fail with an actionable message
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-ONLY", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) {
+ assertOidcFails(auth::signIn, "no id_token");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testGroupsInTokenReturnsIdToken() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-X", "ID-X", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) {
+ Assert.assertEquals("ID-X", auth.signIn());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testHttpSenderProviderFailureAfterFlushDoesNotCorruptSender() throws Exception {
+ assertMemoryLeak(() -> {
+ // regression: the per-request token must be pulled lazily when a row starts, never eagerly when
+ // the post-flush request is rebuilt. A provider that throws on a later pull (e.g.
+ // OidcDeviceAuth::getToken when a refresh fails) must NOT turn an already-successful
+ // flush into a thrown exception, and must NOT leave a half-built request that corrupts the
+ // sender so later rows go out malformed
+ MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.json(204, "");
+ AtomicInteger pulls = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V2)
+ .httpTokenProvider(() -> {
+ int n = pulls.incrementAndGet();
+ if (n == 2) {
+ // the second pull - for the request after the first, successful flush - fails
+ throw new OidcAuthException("the cached token expired and could not be refreshed");
+ }
+ return "TOKEN-" + n;
+ })
+ .build()) {
+ // first batch: the token is pulled when the row starts (TOKEN-1); the flush sends it and must
+ // succeed. The failing *next* pull must not strike here - the eager post-flush pull was the bug
+ sender.table("t").doubleColumn("x", 1.0).atNow();
+ sender.flush();
+
+ // next batch: the deferred pull runs when the row starts and the provider throws there; the
+ // failure must surface cleanly, leaving the previous successful flush and its data untouched
+ try {
+ sender.table("t").doubleColumn("x", 2.0).atNow();
+ Assert.fail("expected the failing provider pull to surface on the next row");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not be refreshed"));
+ Assert.assertTrue("the provider failure must be retained as the cause",
+ e.getCause() instanceof OidcAuthException);
+ }
+
+ // the provider recovers (pull #3 -> TOKEN-3); the failed pull must not have corrupted the
+ // sender, so this row produces a well-formed request the server accepts
+ sender.table("t").doubleColumn("x", 3.0).atNow();
+ sender.flush();
+
+ java.util.List seen = server.requestAuthHeaders();
+ Assert.assertTrue(seen.toString(), seen.contains("Bearer TOKEN-1"));
+ Assert.assertTrue(seen.toString(), seen.contains("Bearer TOKEN-3"));
+ // the failed pull never reached the wire as a partial request
+ Assert.assertFalse(seen.toString(), seen.contains("Bearer TOKEN-2"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testHttpSenderPullsTokenProviderPerRequest() throws Exception {
+ assertMemoryLeak(() -> {
+ // a long-lived HTTP Sender must pull the token from the provider on each request, so a rotating
+ // token (as OidcDeviceAuth produces on refresh) reaches the wire without rebuilding the sender
+ MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.json(204, "");
+ AtomicInteger tokenSeq = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V2)
+ .httpTokenProvider(() -> "TOKEN-" + tokenSeq.incrementAndGet())
+ .build()) {
+ sender.table("t").doubleColumn("x", 1.0).atNow();
+ sender.flush();
+ sender.table("t").doubleColumn("x", 2.0).atNow();
+ sender.flush();
+ // each flush built a fresh request and pulled a fresh token; the server saw successive bearers
+ java.util.List seen = server.requestAuthHeaders();
+ Assert.assertTrue("expected at least 2 write requests, got " + seen, seen.size() >= 2);
+ Assert.assertTrue(seen.toString(), seen.contains("Bearer TOKEN-1"));
+ Assert.assertTrue(seen.toString(), seen.contains("Bearer TOKEN-2"));
+ Assert.assertNotEquals("the token must rotate per request", seen.get(0), seen.get(1));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIncompleteDeviceResponseRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // the device endpoint returns 200 but omits user_code and verification_uri
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(200, "{\"device_code\":\"DEV\",\"expires_in\":300,\"interval\":1}");
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "incomplete device authorization");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIdpEndpointsRequireHttpsExceptLoopback() throws Exception {
+ assertMemoryLeak(() -> {
+ // a non-loopback http identity-provider endpoint carries the device code and refresh token in
+ // cleartext, so it must be refused
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("http://idp.example/device")
+ .tokenEndpoint("https://idp.example/token")
+ .build()
+ ) {
+ Assert.fail("expected the http device authorization endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint"));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http"));
+ }
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://idp.example/device")
+ .tokenEndpoint("http://idp.example/token")
+ .build()
+ ) {
+ Assert.fail("expected the http token endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint"));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http"));
+ }
+ // allowInsecureTransport must NOT relax the identity provider endpoints (unlike the QuestDB
+ // link), matching the Python client; a non-loopback http endpoint stays rejected, and the
+ // error says so
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("http://idp.example/device")
+ .tokenEndpoint("http://idp.example/token")
+ .allowInsecureTransport(true)
+ .build()
+ ) {
+ Assert.fail("allowInsecureTransport must not relax a non-loopback http identity provider endpoint");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http"));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("allowInsecureTransport relaxes only the QuestDB"));
+ }
+ // loopback http is allowed without any flag: the request never leaves the host
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("http://127.0.0.1:9999/device")
+ .tokenEndpoint("http://127.0.0.1:9999/token")
+ .build()
+ ) {
+ // accepted: loopback endpoints never put the device code or refresh token on the network
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingAcceptsEndpointsUnderIssuerPath() throws Exception {
+ assertMemoryLeak(() -> {
+ // a path-based identity provider (Keycloak-style /realms/{realm}): the issuer carries a path and
+ // /settings advertises the endpoints under it, so the flow completes
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ if (SETTINGS_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/device") + "\""
+ + "}}");
+ }
+ if ("/realms/acme/device".equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-REALM", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) {
+ Assert.assertEquals("ACCESS-REALM", auth.signIn());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingRejectsEncodedSlash() throws Exception {
+ assertMemoryLeak(() -> {
+ // the device endpoint hides an extra path segment behind a %2f-encoded slash; decoding it would
+ // split acme%2fevil into acme/evil and slip the "/realms/acme" scope, so an encoded path separator
+ // must be rejected outright
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme%2fevil/device") + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) {
+ Assert.fail("expected the %2f-encoded device endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingRejectsEncodedTraversal() throws Exception {
+ assertMemoryLeak(() -> {
+ // the device endpoint hides a parent traversal as %2e%2e; decoding must unmask it and reject it,
+ // since the server would normalize /realms/acme/../evil to a different realm
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/%2e%2e/evil/device") + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) {
+ Assert.fail("expected the encoded ..-traversal device endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingRejectsMatrixParamTraversal() throws Exception {
+ assertMemoryLeak(() -> {
+ // the device endpoint hides a parent traversal as an RFC 3986 ";matrix" segment (..;): a server or
+ // proxy that strips matrix params resolves /realms/acme/..;/evil to /realms/evil, a DIFFERENT realm.
+ // The plain "." / ".." dot-segment check does not match "..;", so the check must strip the ";suffix"
+ // first and reject it - the origin pin alone cannot stop a sibling-tenant redirect on one host.
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/..;/evil/device") + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) {
+ Assert.fail("expected the ..;-matrix-param traversal device endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingRejectsSiblingRealm() throws Exception {
+ assertMemoryLeak(() -> {
+ // a tampered /settings advertises a token endpoint under a DIFFERENT realm on the same origin; the
+ // origin check alone would accept it, but path scoping must reject it
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/evil/token") + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/device") + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) {
+ Assert.fail("expected the off-path (sibling realm) token endpoint to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingRejectsRawDotSegments() throws Exception {
+ assertMemoryLeak(() -> {
+ // A RAW (unencoded) ".." or "." segment carries no '%' or '\\' and no '?'/'#'/control, so it slips
+ // every earlier gate and reaches the dot-segment scan - the only cases that do. A lenient server
+ // normalizes .../realms/acme/../evil/device into a different realm, so the pin must reject it.
+ assertIssuerScopeAccepts("/realms/acme/device");
+ assertIssuerScopeRejects("/realms/acme/../evil/device");
+ assertIssuerScopeRejects("/realms/acme/./../evil/device");
+ assertIssuerScopeRejects("/realms/./acme/device");
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() throws Exception {
+ assertMemoryLeak(() -> {
+ // An encoded path separator can hide behind a SPLIT encoding (%2%66 -> %2f -> '/'), a double
+ // encoding (%252f), or a backslash that decodePathSegments folds to '/'. Each lets an extra
+ // segment masquerade as being under the issuer path while a different raw path travels on the
+ // wire. Overlong UTF-8 (%c0%ae, %e0%80%ae) and an IIS-style %u002e encode a '.' that a permissive
+ // server resolves but a byte-oriented decode leaves as high bytes, so any '%' in an endpoint path
+ // is refused outright.
+ assertIssuerScopeAccepts("/realms/acme/protocol/device");
+ assertIssuerScopeRejects("/realms/acme%2%66evil/device");
+ assertIssuerScopeRejects("/realms/acme%252fevil/device");
+ assertIssuerScopeRejects("/realms/acme\\evil/device");
+ assertIssuerScopeRejects("/realms/acme%5cevil/device");
+ assertIssuerScopeRejects("/realms/acme%5Cevil/device");
+ assertIssuerScopeRejects("/realms/acme/%c0%ae%c0%ae/evil/device");
+ assertIssuerScopeRejects("/realms/acme/%e0%80%ae%e0%80%ae/evil/device");
+ assertIssuerScopeRejects("/realms/acme/%u002e%u002e/evil/device");
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exception {
+ assertMemoryLeak(() -> {
+ // A real id_token (a JWT with group claims) runs to several KB, and a single JSON string value
+ // can arrive split across HTTP response fragments. OidcDeviceAuth must size its JSON lexer so
+ // such a split value still parses. This mirrors OidcDeviceAuth's production sizing
+ // (JSON_LEXER_CACHE_SIZE / JSON_LEXER_MAX_VALUE_BYTES); the original (1024, 1024) sizing
+ // rejected a >1024-byte split value with "String is too long".
+ String json = "{\"id_token\":\"" + TestUtils.repeat("a", 4000) + "\"}";
+ int len = json.length();
+ int split = "{\"id_token\":\"".length() + 1300; // boundary inside the value, past the old 1024 limit
+ long address = TestUtils.toMemory(json);
+ try {
+ try {
+ parseSplitValue(1024, address, split, len);
+ Assert.fail("the original 1024-byte cache limit must reject a split multi-KB token value");
+ } catch (JsonException expected) {
+ Assert.assertTrue(expected.getFlyweightMessage().toString(),
+ expected.getFlyweightMessage().toString().contains("String is too long"));
+ }
+ // the sizing OidcDeviceAuth now uses parses the same split value
+ parseSplitValue(1 << 20, address, split, len);
+ } finally {
+ Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPlaintextIdpEndpointIsAllowedOnlyForLoopbackHosts() {
+ // The rule the loopback classifier exists to serve: the identity provider endpoints must use https,
+ // because the device code and the refresh token travel over them - EXCEPT to a loopback host, where
+ // the request never leaves the machine. Driven through build(), which does no network I/O, rather
+ // than by reflecting on the private classifier: this asserts the outcome a user actually gets, and
+ // it survives that predicate being renamed, inlined or replaced.
+ //
+ // Both endpoints use the same host because build() also requires them to share an origin; that check
+ // is not what is under test here, it just has to be satisfied for the loopback rows to reach a verdict.
+ String[] loopback = {
+ "localhost", "LOCALHOST", "LocalHost",
+ "127.0.0.1", "127.0.0.0", "127.1.2.3", "127.255.255.255", "127.0.0.255"
+ };
+ for (String host : loopback) {
+ try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("http://" + host + "/device")
+ .tokenEndpoint("http://" + host + "/token")
+ .build()) {
+ Assert.assertNotNull("plaintext to a loopback host must be allowed: [" + host + ']', auth);
+ }
+ }
+
+ // Everything else must be refused over plaintext, so the MITM pin fires. A classifier that accepted
+ // any of these would silently send a device code and a refresh token across the network in the clear.
+ String[] notLoopback = {
+ "example.com", "questdb.example",
+ "127.evil.com", // starts with "127." but is not a dotted-IPv4 literal
+ "localhost.evil.com", // not an exact localhost match
+ "evil.localhost",
+ "0x7f.0.0.1", // hex form is not the dotted 127.0.0.0/8 literal
+ "127.1", "127.0.1", "127", // short forms the OS would expand are deliberately not accepted
+ "127.0.0.256", // octet out of range
+ "127.0.0.1.evil.com", // extra label after a valid prefix
+ "127.0.0.1.", // trailing dot
+ "127..0.1", // empty octet
+ "1270.0.0.1", // does not start with "127."
+ "227.0.0.1", // not the 127 block
+ "0.0.0.0", "10.0.0.1", "192.168.0.1",
+ // A name is accepted on the strength of what it RESOLVES to, not how it is spelt - RFC 6761
+ // says localhost must be loopback, but a host with no /etc/hosts entry leaves that to DNS.
+ // One that does not resolve must fail CLOSED, i.e. be treated as non-loopback. (The hostile
+ // half - localhost resolving off loopback - needs the host's resolver rewritten, so it is
+ // unasserted by design rather than by omission.)
+ "no-such-host.invalid"
+ };
+ for (String host : notLoopback) {
+ assertBuildFails("http://" + host + "/device", "http://" + host + "/token", "use an https url");
+ }
+
+ // Two forms the classifier never sees, because the endpoint parser rejects them first. Asserted here
+ // so the list above is not silently assumed to cover them.
+ assertBuildFails("http:///device", "http:///token", "the host is empty");
+ assertBuildFails("http://[::1]:9000/device", "http://[::1]:9000/token",
+ "IPv6 literal hosts are not supported");
+ }
+
+ @Test(timeout = 30_000)
+ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exception {
+ // The "127.1" reachability trick below depends on the OS resolver expanding the abbreviated IPv4
+ // form to 127.0.0.1 (inet_aton, on Linux/macOS). Windows getaddrinfo - which the native HTTP client
+ // resolves through - does not accept the short form, so the loopback mock is unreachable there.
+ Assume.assumeTrue("requires inet_aton-style short-form IPv4 resolution, unavailable on Windows", Os.type != Os.WINDOWS);
+ assertMemoryLeak(() -> {
+ // the end-to-end firing path of the plaintext-channel MITM pin, which a 127.0.0.1-bound mock
+ // cannot otherwise reach: a non-loopback http /settings that advertises BOTH endpoints (so the
+ // missing-endpoint discovery pin does not apply) must be refused unless the identity provider is
+ // pinned out of band - otherwise a tampered response could route the device code and refresh token
+ // to an attacker. Reaching the mock through "127.1" is the trick: the OS resolver expands the short
+ // form to 127.0.0.1 so the loopback mock answers, but the loopback classifier deliberately rejects
+ // the short form, so the server host is non-loopback and the pin fires.
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH)));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ String questdbUrl = "http://127.1:" + server.port();
+ // without an out-of-band pin the plaintext channel is untrusted: the pin fires
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(questdbUrl, insecure())) {
+ Assert.fail("expected the plaintext-channel pin to reject /settings-supplied endpoints without a pin");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("reached over insecure http"));
+ }
+ // pinning the issuer to the advertised endpoints' origin satisfies the pin over the very same
+ // plaintext channel, so construction succeeds - proving the pin, not some unrelated rejection,
+ // is what gated the unpinned call above
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(questdbUrl, insecure().issuer(server.httpUrl("")))) {
+ Assert.assertNotNull(auth);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRejectedBuildDoesNotLeakNativeMemory() throws Exception {
+ // A build rejected during validation must not leak. build() parses and validates every endpoint
+ // BEFORE the constructor runs, and the constructor allocates the native JSON lexer LAST (after
+ // urlEncode and the TokenStoreKey build, either of which can throw), so a rejected build never
+ // allocates the lexer and the never-returned instance cannot be closed to free it. Use a
+ // parseable-but-rejected config - endpoints that parse cleanly but fail the https requirement - so the
+ // rejection lands AFTER endpoint parsing, exercising more of build() than a syntactically bad url
+ // would. testSuccessfulBuildAndCloseDoNotLeakNativeMemory covers the complementary lexer-allocated
+ // path. assertMemoryLeak guards every tag; the parser-tag assertion stays because it names the buffer.
+ assertMemoryLeak(() -> {
+ long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS);
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("http://idp.example/device") // parses fine, but plaintext http to a non-loopback host
+ .tokenEndpoint("https://idp.example/token")
+ .allowInsecureTransport(false)
+ .build()
+ ) {
+ Assert.fail("expected the https requirement to reject the plaintext device endpoint");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("use an https url"));
+ }
+ Assert.assertEquals("a rejected build must not leak the JSON lexer native buffer",
+ parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS));
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSuccessfulBuildAndCloseDoNotLeakNativeMemory() throws Exception {
+ // The complement to the rejected-build case: a SUCCESSFUL build is the only path that allocates the
+ // native JSON lexer, so this is the block that actually exercises a lexer-allocated instance, and
+ // close() must free it. Loop a few build->close cycles so any per-cycle leak accrues, then assert the
+ // parser tag returns to its baseline. build() does no network I/O (discovery is separate), so valid
+ // co-located https endpoints construct offline. assertMemoryLeak guards every tag; the parser-tag
+ // assertion stays because it names the buffer.
+ assertMemoryLeak(() -> {
+ long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS);
+ for (int i = 0; i < 4; i++) {
+ try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://idp.example/device")
+ .tokenEndpoint("https://idp.example/token")
+ .build()) {
+ Assert.assertNotNull(auth);
+ }
+ }
+ Assert.assertEquals("close() must free the JSON lexer native buffer allocated by a successful build",
+ parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS));
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNoAccessTokenWhenGroupsDisabledFails() throws Exception {
+ assertMemoryLeak(() -> {
+ // groups not in token, but the IdP returns only an id token; signIn() must fail
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson(null, "ID-ONLY", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "no access_token");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNonSuccessDeviceAuthorizationResponseRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // RFC 8628 3.2: a device authorization grant is a 2xx response. A non-2xx body that nonetheless
+ // carries device_code/user_code/verification_uri and no OAuth error must be rejected - the client
+ // must not prompt the user and poll on a response the server never signalled success for
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(403, deviceAuthorizationJson(1, 300));
+ AtomicBoolean prompted = new AtomicBoolean(false);
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, challenge -> prompted.set(true))) {
+ assertOidcFails(auth::signIn, "unexpected response from the device authorization endpoint",
+ "expected the non-2xx device authorization response to be rejected");
+ Assert.assertFalse("the user must not be prompted on a rejected device authorization response", prompted.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNullAccessTokenNotServedAsLiteralNull() throws Exception {
+ assertMemoryLeak(() -> {
+ // a JSON null arrives from the lexer as the literal "null"; "access_token": null must be treated
+ // as absent, not stored and served as the 4-char token "null"
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, "{\"token_type\":\"Bearer\",\"expires_in\":3600,\"access_token\":null}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ // null is absent, so a 2xx with no token is a definitive but malformed answer. The token the
+ // call would have served, had it wrongly served the literal "null", is in the failure message
+ // assertOidcFails builds.
+ assertOidcFails(auth::signIn, "unexpected response",
+ "a JSON null access_token must not be served as the literal token \"null\"");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNullJsonErrorIsTreatedAsAbsent() throws Exception {
+ assertMemoryLeak(() -> {
+ // "error": null in a device-auth response must be treated as absent, not as an OAuth error whose
+ // code is the literal string "null"; the flow must proceed to prompt and poll
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"WDJB\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"error\":null,"
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testNullPromptDefaultsToSystemOut() throws Exception {
+ assertMemoryLeak(() -> {
+ // builder.prompt(null) must fall back to the default prompt rather than NPE during the flow
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-NP", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .prompt(null)
+ .allowInsecureTransport(true)
+ .build()) {
+ // no NPE: the flow runs to completion with the default SYSTEM_OUT prompt
+ Assert.assertEquals("ACCESS-NP", auth.signIn());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testOauthErrorMessageStripsBidiControls() throws Exception {
+ assertMemoryLeak(() -> {
+ // an IdP error_description carrying a right-to-left override and a zero-width space (as JSON
+ // unicode escapes the lexer decodes) must not reach the exception message verbatim; they would
+ // let a malicious IdP reorder or hide text when the message is rendered to a terminal or a log
+ String desc = "denied" + jsonUnicodeEscape(0x202E) + "reversed" + jsonUnicodeEscape(0x200B) + "end";
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"" + desc + "\"}");
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "access_denied");
+ Assert.assertEquals("access_denied", e.getOauthError());
+ String msg = e.getMessage();
+ assertNoUnsafeDisplayChars(msg);
+ Assert.assertTrue(msg, msg.contains("deniedreversedend")); // readable text survives, controls gone
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testOauthErrorMessageStripsControlChars() throws Exception {
+ assertMemoryLeak(() -> {
+ // an IdP error_description carrying ANSI/CRLF control chars must not reach the exception
+ // message verbatim (it would let a malicious IdP rewrite the terminal or forge log lines)
+ String desc = "denied" + ((char) 0x1b) + "[2J\r\nFAKE: paste your token";
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"" + desc + "\"}");
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "access_denied");
+ Assert.assertEquals("access_denied", e.getOauthError());
+ String msg = e.getMessage();
+ assertNoControlChars(msg);
+ Assert.assertTrue(msg, msg.contains("FAKE: paste your token")); // readable text survives
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testOutOfRangePollIntervalAndExpiryAreClamped() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile or misconfigured identity provider reports an absurd interval/expires_in; the
+ // client must clamp both, so interval*1000 cannot overflow into a zero-delay busy loop and
+ // the wait cannot run absurdly long
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(2_000_000_000, 2_000_000_000));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-CLAMP", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-CLAMP", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ // the absurd interval/expires_in are clamped to the documented maxima: the poll interval to
+ // MAX_POLL_INTERVAL_SECONDS (60) and the device-code lifetime to MAX_DEVICE_CODE_TTL_SECONDS (1800)
+ Assert.assertEquals(60, challenge.getIntervalSeconds());
+ Assert.assertEquals(1800, challenge.getExpiresInSeconds());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testOversizedSettingsBodyAbortsAtSizeCap() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile or MITM'd server streams a /settings body larger than the client's response-size cap
+ // (MAX_RESPONSE_BODY_BYTES, 4 MiB); the bounded read must abort on the cap rather than consume the
+ // body without limit. Stream well past the cap - the client stops reading and closes the
+ // connection once it crosses 4 MiB
+ MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.oversizedJson(8L * 1024 * 1024);
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected discovery to abort on the response-size cap");
+ } catch (OidcAuthException e) {
+ // the size-cap failure surfaces as the cause; the body (which carries access/id/refresh
+ // tokens on a real response) is never embedded in the message
+ Throwable cause = e.getCause();
+ Assert.assertNotNull("expected the size-cap failure as the cause", cause);
+ Assert.assertTrue(cause.getMessage(), cause.getMessage().contains("exceeded the size limit"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPollAbortDropsDirtyConnectionAndReconnects() throws Exception {
+ assertMemoryLeak(() -> {
+ // the token endpoint stalls the body on the first poll, so the bounded read aborts with the
+ // response half-read and unconsumed bytes left in the cached keep-alive connection. The poll loop
+ // must drop that connection and reconnect for the next poll, not reuse it: the stalled mock thread
+ // never reads a reused connection, so reusing it would leave every later poll unanswered until the
+ // device code expires (and, for a non-stalled dirty connection, would mis-frame the next response
+ // against this one's leftovers). With the reconnect, the second poll reaches a fresh connection and
+ // succeeds. Without the fix this test hangs until the 10s device-code lifetime and signIn throws.
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ // short lifetime, well under the 30s mock stall and the 30s test timeout, so the no-fix
+ // failure (poll the dirty connection until expiry) surfaces deterministically and fast
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 10));
+ }
+ if (tokenCalls.getAndIncrement() == 0) {
+ return MockOidcServer.stall();
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-RECONNECTED", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .httpTimeoutMillis(1_000) // abort the stalled body read quickly
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ Assert.assertEquals("ACCESS-RECONNECTED", auth.signIn());
+ Assert.assertEquals(2, tokenCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPollIntervalClampedTo60() throws Exception {
+ assertMemoryLeak(() -> {
+ // the identity-provider-reported poll interval is capped at 60s (matching the Python client); the
+ // clamped value is the one shown to the user and used between polls. A short-lived device code
+ // ends the flow quickly via timeout, once the interval has been captured by the prompt.
+ AtomicReference shown = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(999, 2));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ assertOidcFails(auth::signIn, "device code expired", "expected the device code to expire");
+ Assert.assertEquals(60, shown.get().getIntervalSeconds());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRateLimited429WithTerminalErrorAbortsImmediately() throws Exception {
+ assertMemoryLeak(() -> {
+ // a 429 that ALSO carries a terminal OAuth error must fail fast on the error, not back off and poll
+ // to the device-code deadline: pollOnce handles the OAuth error before the 429 rate-limit backoff
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 5));
+ }
+ return MockOidcServer.json(429, "{\"error\":\"access_denied\",\"error_description\":\"the user declined\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ try {
+ auth.signIn();
+ Assert.fail("expected the terminal OAuth error to abort despite the 429 status");
+ } catch (OidcAuthException e) {
+ Assert.assertEquals("access_denied", e.getOauthError());
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("device code expired"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRateLimitedTokenEndpointBacksOffInsteadOfFailingFast() throws Exception {
+ assertMemoryLeak(() -> {
+ // HTTP 429 is a transient backoff (poll slower, keep polling), matching the Python client, not a
+ // terminal rejection. The token endpoint always returns 429, so the flow ends only when the
+ // short-lived device code expires - proving polling continued rather than failing fast.
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 4));
+ }
+ return MockOidcServer.json(429, "{}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "device code expired",
+ "expected the device code to expire while the token endpoint kept returning 429");
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPersistentTransportFailureKeepsPollingToDeadline() throws Exception {
+ assertMemoryLeak(() -> {
+ // the device endpoint works, but the (co-located) token endpoint drops the connection on every
+ // poll. Matching the Python client, a transport failure is transient - the user may already have
+ // authorized - so polling continues until the device code expires rather than failing fast. The
+ // endpoints share one origin so the build-time co-location check passes; the mock simulates the
+ // unreachable token endpoint by dropping the connection.
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 3));
+ }
+ return MockOidcServer.dropConnection();
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ auth.signIn();
+ Assert.fail("expected the device code to expire while the token endpoint kept dropping");
+ } catch (OidcAuthException e) {
+ // polled to the deadline (device code expired), not a fast transport abort
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired"));
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("unreachable"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPersistent5xxDuringPollingKeepsPollingToDeadline() throws Exception {
+ assertMemoryLeak(() -> {
+ // a 5xx from the token endpoint is a transient server/gateway condition: keep polling to the
+ // device-code deadline rather than failing fast, matching the Python client
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 3));
+ }
+ return MockOidcServer.json(503, "{}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "device code expired",
+ "expected the device code to expire while the token endpoint returned 503");
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTerminal4xxDuringPollingFailsFast() throws Exception {
+ assertMemoryLeak(() -> {
+ // a 4xx from the token endpoint with no OAuth error (e.g. a WAF or proxy rejection) is terminal:
+ // fail fast rather than poll on to a misleading "device code expired", matching the Python client
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(403, "{}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "rejected the request",
+ "expected a terminal 4xx to fail fast");
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("device code expired"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshErrorFallsBackToInteractiveFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // the cached token expires and the refresh is rejected (revoked/expired refresh token);
+ // the client must fall back to a fresh interactive sign-in
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger deviceCodeGrants = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ return MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}");
+ }
+ if (deviceCodeGrants.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // the refresh is rejected, so the flow re-runs the interactive sign-in
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshedTokenWithControlCharFallsBackToInteractiveFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // A silent refresh whose 200 response carries a served token with a control character - here an
+ // escaped \r that JsonLexer now decodes into a real CR byte - must be rejected by storeTokens ->
+ // validateTokenChars, and tryRefresh must SWALLOW that rejection and fall back to the interactive
+ // device flow rather than let it propagate out of signIn()/getToken(). Guards the tryRefresh
+ // storeTokens try/catch: without it, this signIn() throws instead of returning the fallback token.
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger deviceCodeGrants = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // valid-JSON 200, but the access_token carries an escaped CR (\r on the wire); the served
+ // kind is validated, so validateTokenChars must reject it before it is cached
+ return MockOidcServer.json(200, tokenJson("ACCESS\\r2", null, "REFRESH-2", 3600));
+ }
+ // the initial device-code grant uses a short TTL so the next signIn() triggers a refresh
+ if (deviceCodeGrants.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
+ }
+ // the fallback interactive grant, after the poisoned refresh is rejected
+ return MockOidcServer.json(200, tokenJson("ACCESS-3", null, "REFRESH-3", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // the refresh returns a control-char token -> rejected -> fall back to a fresh interactive sign-in
+ Assert.assertEquals("ACCESS-3", auth.signIn());
+ Assert.assertEquals("the interactive flow must run twice (initial + fallback after the rejected refresh)",
+ 2, deviceCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshKeepsExistingRefreshTokenWhenOmitted() throws Exception {
+ assertMemoryLeak(() -> {
+ // a refresh response that omits refresh_token (RFC 6749 permits this) must not drop the existing
+ // refresh token; a later refresh must reuse it rather than fall back to a fresh interactive sign-in
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger refreshCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // every refresh must present the ORIGINAL refresh token, and returns a short-lived
+ // access token WITHOUT a new refresh_token
+ Assert.assertTrue(body, body.contains("refresh_token=REFRESH-1"));
+ int n = refreshCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-R" + n, null, null, 1));
+ }
+ // the initial device-code grant: a short-lived access token plus the refresh token
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // first refresh omits refresh_token, so REFRESH-1 must be kept
+ Assert.assertEquals("ACCESS-R1", auth.signIn());
+ expireCachedToken(auth);
+ // second refresh must still present the retained REFRESH-1 (asserted in the handler)
+ Assert.assertEquals("ACCESS-R2", auth.signIn());
+ Assert.assertEquals("no extra interactive sign-in", 1, deviceCalls.get());
+ Assert.assertEquals(2, refreshCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // a refresh response that carries an OAuth error (under a non-2xx status) must not be trusted
+ // even if it also returns a token; the client ignores the smuggled token and falls back to a
+ // fresh interactive sign-in rather than caching it
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger deviceCodeGrants = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // malformed: a 400 error together with a token
+ return MockOidcServer.json(400, "{\"error\":\"invalid_grant\",\"access_token\":\"SHOULD-NOT-BE-USED\"}");
+ }
+ if (deviceCodeGrants.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // the refresh carries an error+token, so the client must ignore the smuggled token and
+ // re-run the interactive flow
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testInterruptDuringTheRefreshDoesNotStartTheDeviceFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // signIn() guards the interrupt flag on entry, but everything after that guard is network work:
+ // the silent refresh is a round trip bounded by four times httpTimeoutMillis plus an OS connect
+ // stall. A cancellation landing inside it is the ORDINARY case rather than a narrow race, because
+ // a caller gives up precisely when a refresh is dragging - and proceeding then launches a browser
+ // and parks for the device-code lifetime on a thread whose owner already asked it to stop.
+ AtomicInteger deviceCalls = new AtomicInteger();
+ CountDownLatch refreshReceived = new CountDownLatch(1);
+ CountDownLatch interruptSent = new CountDownLatch(1);
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // hold the refresh open until the test has interrupted the worker, then fail it so the
+ // flow reaches the point where it would otherwise prompt
+ refreshReceived.countDown();
+ try {
+ interruptSent.await(20, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 1));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ Assert.assertEquals(1, deviceCalls.get());
+ expireCachedToken(auth);
+
+ AtomicReference failure = new AtomicReference<>();
+ AtomicBoolean flagSurvived = new AtomicBoolean();
+ Thread worker = new Thread(() -> {
+ try {
+ auth.signIn();
+ failure.compareAndSet(null, new AssertionError("signIn() must abandon a cancelled sign-in"));
+ } catch (OidcAuthException expected) {
+ if (!expected.getMessage().contains("interrupted")) {
+ failure.compareAndSet(null, expected);
+ }
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ } finally {
+ // read it, do not clear it: the caller's shutdown path is what waits on this flag
+ flagSurvived.set(Thread.currentThread().isInterrupted());
+ }
+ }, "cancelled-signin");
+ worker.start();
+
+ Assert.assertTrue("the refresh must reach the server",
+ refreshReceived.await(20, TimeUnit.SECONDS));
+ worker.interrupt();
+ interruptSent.countDown();
+ worker.join(20_000);
+ Assert.assertFalse("signIn() did not return after the cancellation", worker.isAlive());
+
+ Assert.assertNull(String.valueOf(failure.get()), failure.get());
+ Assert.assertEquals("a cancelled sign-in must not open a browser or start a device grant",
+ 1, deviceCalls.get());
+ Assert.assertTrue("the interrupt is the caller's cancellation signal and must survive "
+ + "signIn(), or their own shutdown bookkeeping reads as never-cancelled",
+ flagSurvived.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testInterruptDuringThePollLoopAbandonsItAndKeepsTheFlag() throws Exception {
+ assertMemoryLeak(() -> {
+ // The other half: an interrupt that arrives once the poll loop is already running. Os.sleep
+ // catches InterruptedException, recomputes its deadline and keeps sleeping - and Thread.sleep
+ // clears the flag when it throws - so the loop both ignored the cancellation AND destroyed the
+ // evidence of it, then polled on to the device-code lifetime.
+ //
+ // The prompt runs on the sign-in thread, immediately before the poll loop, so interrupting from
+ // there lands the cancellation exactly where the loop must notice it.
+ AtomicInteger tokenPolls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 1800));
+ }
+ tokenPolls.incrementAndGet();
+ return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}");
+ };
+ DeviceCodePrompt cancellingPrompt = challenge -> Thread.currentThread().interrupt();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, cancellingPrompt)) {
+ long startNanos = System.nanoTime();
+ try {
+ auth.signIn();
+ Assert.fail("the poll loop must abandon a cancelled sign-in");
+ } catch (OidcAuthException expected) {
+ Assert.assertTrue(expected.getMessage(),
+ expected.getMessage().contains("interrupted"));
+ }
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+
+ Assert.assertTrue("the interrupt must survive the poll loop", Thread.interrupted());
+ // the device code is good for 1800s; a loop that ignores the interrupt runs to the @Test
+ // timeout instead, so this ceiling is what separates the two
+ Assert.assertTrue("the poll loop ran on past the cancellation: " + elapsedMillis + "ms",
+ elapsedMillis < 10_000);
+ Assert.assertTrue("the loop must not keep polling after the cancellation, saw "
+ + tokenPolls.get() + " polls", tokenPolls.get() <= 1);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshWithoutIdTokenAdoptsRotatedRefreshToken() throws Exception {
+ assertMemoryLeak(() -> {
+ // A refresh may legally answer 2xx without an id_token (RFC 6749 6; OIDC Core 12.2), which a
+ // groupsInToken client cannot serve - but that response is still a clean grant, and a ROTATING
+ // provider has already invalidated the refresh token we presented. Dropping the whole response
+ // therefore keeps a spent credential: every later refresh replays it, and a reuse-detecting
+ // provider answers a replay by revoking the entire token family.
+ //
+ // The observable is what reaches the wire: each refresh records the refresh_token it presented,
+ // so a replay shows up as the same value twice. The device endpoint is available for the FIRST
+ // sign-in only - afterwards it errors, so the interactive fallback cannot mint a fresh refresh
+ // token and mask which one the refresh path kept.
+ StringSink presented = new StringSink();
+ AtomicInteger deviceAuthCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ if (deviceAuthCalls.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"access_denied\"}");
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ if (presented.length() > 0) {
+ presented.put(',');
+ }
+ presented.put(refreshTokenParam(body));
+ // a clean 2xx, no id_token, and the refresh token ROTATES
+ return MockOidcServer.json(200, tokenJson("ACCESS-R", null, "REFRESH-2", 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 1));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) {
+ Assert.assertEquals("ID-1", auth.signIn());
+
+ // signIn() attempts the silent refresh BEFORE prompting, so each of these two calls puts
+ // exactly one refresh on the wire and then fails over to a device endpoint that refuses.
+ for (int i = 0; i < 2; i++) {
+ expireCachedToken(auth);
+ try {
+ auth.signIn();
+ Assert.fail("the device endpoint refuses after the first sign-in");
+ } catch (OidcAuthException expected) {
+ Assert.assertTrue(expected.getMessage(),
+ expected.getMessage().contains("access_denied"));
+ }
+ }
+
+ Assert.assertEquals("the rotated refresh token must replace the one the provider burned; "
+ + "presenting the same value twice is the replay a reuse-detecting provider "
+ + "answers by revoking the whole token family",
+ "REFRESH-1,REFRESH-2", presented.toString());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Exception {
+ assertMemoryLeak(() -> {
+ // groups are encoded in the token (the default enterprise config), so signIn() serves the
+ // id token. The cached token expires and the refresh response omits id_token (RFC 6749 makes
+ // it optional on refresh), so the client must re-run the interactive flow rather than fail.
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger deviceCodeGrants = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ // a refresh that returns a fresh access token but no id_token
+ return MockOidcServer.json(200, tokenJson("ACCESS-R", null, null, 3600));
+ }
+ // the device-code grant: first a soon-expired token, then (after fallback) a fresh one
+ if (deviceCodeGrants.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 1));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", "REFRESH-2", 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) {
+ Assert.assertEquals("ID-1", auth.signIn());
+ expireCachedToken(auth);
+ // the refresh returns no id_token, so the flow falls back to interactive sign-in and
+ // returns the fresh id token instead of throwing "returned no id_token"
+ Assert.assertEquals("ID-2", auth.signIn());
+ Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerErrorDuringPollingRetries() throws Exception {
+ assertMemoryLeak(() -> {
+ // the token endpoint returns a gateway 5xx with an empty body once (no JSON error), then a
+ // token. An empty-bodied upstream blip must be retried, not aborted as an "unexpected response"
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (tokenCalls.getAndIncrement() == 0) {
+ return MockOidcServer.json(502, "");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-RECOVERED-5XX", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-RECOVERED-5XX", auth.signIn());
+ Assert.assertEquals(2, tokenCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSilentRefreshWhenTokenExpired() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger promptCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (body.contains("grant_type=refresh_token")) {
+ Assert.assertTrue(body, body.contains("refresh_token=REFRESH-1"));
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", null, 3600));
+ }
+ // initial device-code grant, hand out a token that is already expired vs the clock skew
+ return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 1));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) {
+ Assert.assertEquals("ACCESS-1", auth.signIn());
+ expireCachedToken(auth);
+ // the cached token is expired, so the second call refreshes silently
+ Assert.assertEquals("ACCESS-2", auth.signIn());
+ Assert.assertEquals("the interactive flow must run only once", 1, deviceCalls.get());
+ Assert.assertEquals("the user must be prompted only once", 1, promptCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testSlowDownIncreasesIntervalAndSucceeds() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger tokenCalls = new AtomicInteger();
+ AtomicLong firstPollNanos = new AtomicLong();
+ AtomicLong secondPollNanos = new AtomicLong();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ int call = tokenCalls.getAndIncrement();
+ if (call == 0) {
+ firstPollNanos.set(System.nanoTime());
+ return MockOidcServer.json(400, "{\"error\":\"slow_down\"}");
+ }
+ if (call == 1) {
+ secondPollNanos.set(System.nanoTime());
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-S", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-S", auth.signIn());
+ Assert.assertEquals(2, tokenCalls.get());
+ // base interval is 1s; the slow_down must add ~5s, so the SECOND poll lands ~6s after
+ // the first. Assert the inter-poll gap directly, not just total elapsed - without the
+ // increment the gap would be ~1s.
+ long gapMillis = (secondPollNanos.get() - firstPollNanos.get()) / 1_000_000L;
+ Assert.assertTrue("inter-poll gap=" + gapMillis + "ms", gapMillis >= 4_000);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testStalledResponseBodyAbortsWithinTimeout() throws Exception {
+ assertMemoryLeak(() -> {
+ // a server that sends headers then stalls the body must not wedge the thread on the 10-minute
+ // HttpClient default timeout; the body read aborts on the configured OIDC timeout instead
+ MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.stall();
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ long startNanos = System.nanoTime();
+ try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .httpTimeoutMillis(1_000)
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ auth.signIn();
+ Assert.fail("expected the stalled body read to abort");
+ } catch (OidcAuthException e) {
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ // aborted on the configured ~1s OIDC timeout: not instantly (which would be a different
+ // failure path) and not on the 600s HttpClient default (or an indefinite wedge). The window
+ // proves the 1s timeout fired, with generous headroom for a slow CI host
+ Assert.assertTrue("aborted too fast to be the 1s timeout: " + elapsedMillis + "ms", elapsedMillis >= 500);
+ Assert.assertTrue("aborted too slowly for the 1s timeout: " + elapsedMillis + "ms", elapsedMillis < 5_000);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTimesOutWhenCodeExpires() throws Exception {
+ assertMemoryLeak(() -> {
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ // very short lifetime so the poll loop gives up quickly
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 1));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "timed out", "expected a timeout");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenAlongsideOauthErrorIsRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // RFC 6749 5.2: an error response must not be treated as a grant even if the body also carries
+ // a token. A hostile or buggy IdP returns access_denied together with an access_token; the
+ // client must surface the error, not cache the smuggled token
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(400, "{\"error\":\"access_denied\",\"access_token\":\"SHOULD-NOT-BE-USED\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ try {
+ auth.signIn();
+ Assert.fail("expected the error response to be rejected, not the smuggled token accepted");
+ } catch (OidcAuthException e) {
+ Assert.assertEquals("access_denied", e.getOauthError());
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenCachedAcrossCalls() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ tokenCalls.incrementAndGet();
+ return MockOidcServer.json(200, tokenJson("ACCESS-C", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-C", auth.signIn());
+ Assert.assertEquals("ACCESS-C", auth.signIn());
+ Assert.assertEquals("ACCESS-C", auth.signIn());
+ Assert.assertEquals("the interactive flow must run only once", 1, deviceCalls.get());
+ Assert.assertEquals("the token endpoint must be hit only once", 1, tokenCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception {
+ assertMemoryLeak(() -> {
+ final String secret = "SUPER-SECRET-TOKEN-VALUE-0123456789";
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ // a 4xx (terminal) carrying a token but malformed JSON: the parser fails, and the raw body
+ // (with the token) must NOT be echoed into the exception message
+ return MockOidcServer.json(400, "{\"access_token\":\"" + secret + "\" not-valid-json}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "httpStatus=");
+ Assert.assertFalse("the token must not leak into the message: " + e.getMessage(),
+ e.getMessage().contains(secret));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenResponseExpiresInIsClamped() throws Exception {
+ assertMemoryLeak(() -> {
+ // an absurd token-response expires_in (here Integer.MAX_VALUE, ~68 years) must be clamped to
+ // MAX_EXPIRES_IN_SECONDS (1h) like the device-side value, so the client does not trust a stale
+ // cached token for decades (the server still enforces the real expiry).
+ AtomicInteger deviceCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ // no refresh_token, so an expired cache forces a fresh device flow rather than a silent refresh
+ return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, Integer.MAX_VALUE));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .scope("openid")
+ .prompt(noopPrompt())
+ .allowInsecureTransport(true)
+ .build()) {
+ long before = System.currentTimeMillis();
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ long after = System.currentTimeMillis();
+ Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get());
+
+ // the cached expiry must be ~1h out (the clamp), not ~68 years
+ long maxLifetimeMillis = 3600L * 1000L;
+ long expiresAt = readExpiresAtMillis(auth);
+ Assert.assertTrue("expiry must be clamped to <= 1h ahead, was " + (expiresAt - before) + "ms ahead",
+ expiresAt <= after + maxLifetimeMillis);
+ Assert.assertTrue("expiry must be ~1h ahead (the clamp), was " + (expiresAt - after) + "ms ahead",
+ expiresAt >= before + maxLifetimeMillis - 5_000L);
+
+ // once the clamped token is past expiry, with no refresh token signIn() re-runs the device flow
+ expireCachedToken(auth);
+ Assert.assertEquals("ACCESS-OK", auth.signIn());
+ Assert.assertEquals("expired clamped token forces a fresh sign-in", 2, deviceCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenResponseExpiresInZeroUsesDefaultTtl() throws Exception {
+ assertMemoryLeak(() -> {
+ // a token response with a non-positive expires_in (here 0) must fall back to
+ // DEFAULT_TOKEN_TTL_SECONDS (5 min), not be treated as already-expired or cached forever.
+ // testTokenResponseExpiresInIsClamped covers the absurd-large end; this covers the <= 0 default.
+ AtomicInteger deviceCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ // no refresh_token, so an expired cache forces a fresh device flow rather than a silent refresh
+ return MockOidcServer.json(200, tokenJson("ACCESS-DEF", null, null, 0)); // expires_in = 0
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ long before = System.currentTimeMillis();
+ Assert.assertEquals("ACCESS-DEF", auth.signIn());
+ long after = System.currentTimeMillis();
+ Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get());
+
+ // the cached expiry must be ~5min out (the default), neither ~now (treated as expired) nor far
+ long defaultTtlMillis = 300L * 1000L;
+ long expiresAt = readExpiresAtMillis(auth);
+ Assert.assertTrue("expiry must be ~5min ahead (the default), was " + (expiresAt - after) + "ms ahead",
+ expiresAt >= before + defaultTtlMillis - 5_000L);
+ Assert.assertTrue("expiry must be ~5min ahead (the default), not longer, was " + (expiresAt - before) + "ms ahead",
+ expiresAt <= after + defaultTtlMillis);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenUnderNonSuccessStatusIsNotAccepted() throws Exception {
+ assertMemoryLeak(() -> {
+ // RFC 6749 5.1: a token must come from a 2xx response. A token under a non-2xx status with no
+ // OAuth error is a malformed or hostile answer; the client must not cache it - a 4xx is a
+ // terminal rejection that fails fast rather than trusting the token
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(400, "{\"access_token\":\"SHOULD-NOT-BE-USED\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "rejected the request",
+ "expected a token under a 400 to be rejected, not accepted");
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenWithControlCharsRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // a hostile or man-in-the-middled identity provider returns an access token whose JSON value carries
+ // an escaped CR/LF; the lexer decodes it to real control bytes, which - sent verbatim in the
+ // Authorization header to the trusted QuestDB server - would inject into the request line. storeTokens
+ // must reject the token rather than cache and serve it, and must not leak the token into the message
+ String injected = "header.payload" + jsonUnicodeEscape(0x0d) + jsonUnicodeEscape(0x0a) + "X-Injected:1";
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson(injected, null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "disallowed control or non-ASCII",
+ "expected a token with control characters to be rejected");
+ // the token bytes must never leak into the message
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("X-Injected"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenWithNonAsciiCharRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // the > 0x7e arm of the token guard (testTokenWithControlCharsRejected covers the < 0x20 arm):
+ // a non-ASCII char (here U+00E9, not a control char) in the access token would be silently
+ // truncated to one byte by the ASCII Authorization-header writer, yielding a corrupt credential.
+ // storeTokens must reject it, and must not leak the token into the message
+ String injected = "header.payload" + jsonUnicodeEscape(0x00e9) + "SHOULD-NOT-LEAK"; // e-acute, > 0x7e
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, tokenJson(injected, null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ OidcAuthException e = assertOidcFails(auth::signIn, "disallowed control or non-ASCII",
+ "expected a token with a non-ASCII character to be rejected");
+ // the token bytes must never leak into the message
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-LEAK"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTransientParseFailureDuringPollingRecovers() throws Exception {
+ assertMemoryLeak(() -> {
+ // the token endpoint returns a garbled (non-JSON) body once, then a valid token; a transient
+ // parse failure is retried like a transport blip rather than aborting the sign-in
+ AtomicInteger tokenCalls = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ if (tokenCalls.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, "502 Bad Gateway");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-RECOVERED", null, null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("ACCESS-RECOVERED", auth.signIn());
+ Assert.assertEquals(2, tokenCalls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTruncatedSettingsResponseRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // the /settings body is cut off mid-object (HTTP framing satisfied, JSON unterminated). discovery
+ // must reject it as a parse failure, not silently discover from the partial document and report a
+ // misleading "does not advertise ..." error
+ MockOidcServer.Handler handler = (method, path, body) ->
+ MockOidcServer.json(200, "{\"config\":{\"acl.oidc.enabled\":true,\"acl.oidc.client.id\":\"questdb\"");
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) {
+ Assert.fail("expected discovery to reject the truncated settings body");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not parse"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTruncatedTokenResponseRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // a token response whose Content-Length is satisfied but whose JSON is unterminated must be
+ // rejected (parseLast catches the dangling value), not silently treated as no token. A 4xx makes
+ // the parse failure terminal so it surfaces immediately (a malformed 2xx is retried as transient).
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(400, "{\"access_token\":\"abc");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "could not parse");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testUnexpectedTokenResponseRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // the token endpoint returns 200 with neither tokens nor an error
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ return MockOidcServer.json(200, "{\"token_type\":\"Bearer\"}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ assertOidcFails(auth::signIn, "unexpected response");
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testUnreachableDeviceEndpointThrowsOidcAuthException() throws Exception {
+ assertMemoryLeak(() -> {
+ // a connection failure to the device endpoint must surface as OidcAuthException (signIn's
+ // documented failure type), not a raw HttpClientException
+ int deadPort;
+ try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ deadPort = probe.getLocalPort();
+ } // closed now - nothing listens on deadPort
+ try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint("http://127.0.0.1:" + deadPort + "/device")
+ .tokenEndpoint("http://127.0.0.1:" + deadPort + "/token")
+ .allowInsecureTransport(true)
+ .prompt(noopPrompt())
+ .build()) {
+ auth.signIn();
+ Assert.fail("expected an OidcAuthException");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint"));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testUseAfterCloseThrowsClearly() {
+ // calling signIn()/clearCache() after close() must fail with a clear "closed" error rather than
+ // NPE on the freed JSON lexer or resurrect (and leak) a fresh native HTTP client
+ long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS);
+ // close() is the subject under test, so it is called explicitly mid-body; the try-with-resources
+ // close at scope exit is a harmless idempotent second close that also covers an early assertion throw
+ try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint("https://idp.example/device")
+ .tokenEndpoint("https://idp.example/token")
+ .build()
+ ) {
+ auth.close();
+ assertOidcFails(auth::signIn, "closed", "expected signIn() after close() to be rejected");
+ try {
+ auth.clearCache();
+ Assert.fail("expected clearCache() after close() to be rejected");
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed"));
+ }
+ // signIn() must reject before resurrecting a native HTTP client, and close() must have freed
+ // the JSON lexer, so the parser-tag memory returns to its pre-construction level
+ Assert.assertEquals("a closed instance must not leak or resurrect native memory",
+ parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS));
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testVerificationUrlAliasesParsed() throws Exception {
+ assertMemoryLeak(() -> {
+ // some identity providers (historically Google) return verification_url / verification_url_complete
+ // instead of the RFC 8628 verification_uri / verification_uri_complete; both spellings must populate
+ // the challenge shown to the user
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV-CODE\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_url\":\"https://verify.example/device\","
+ + "\"verification_url_complete\":\"https://verify.example/device?user_code=WDJB-MJHT\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-ALIAS", null, null, 3600));
+ };
+ AtomicReference shown = new AtomicReference<>();
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, shown::set)) {
+ Assert.assertEquals("ACCESS-ALIAS", auth.signIn());
+ DeviceAuthorizationChallenge challenge = shown.get();
+ Assert.assertNotNull(challenge);
+ Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri());
+ Assert.assertEquals("https://verify.example/device?user_code=WDJB-MJHT", challenge.getVerificationUriComplete());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testWrongTokenKindDoesNotWedgeCache() throws Exception {
+ assertMemoryLeak(() -> {
+ // groups-in-token mode, but the IdP returns only an access token on the first grant (e.g. the
+ // requested scope omitted openid). signIn() must fail the first call, then re-run the
+ // interactive flow on the next call - not cache the unusable access token as valid and keep
+ // throwing "no id_token" on every later call
+ AtomicInteger deviceCalls = new AtomicInteger();
+ AtomicInteger deviceCodeGrants = new AtomicInteger();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ deviceCalls.incrementAndGet();
+ return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
+ }
+ // first grant: access token only (no id_token); second grant: a proper id token
+ if (deviceCodeGrants.getAndIncrement() == 0) {
+ return MockOidcServer.json(200, tokenJson("ACCESS-ONLY", null, null, 3600));
+ }
+ return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) {
+ assertOidcFails(auth::signIn, "no id_token", "expected an OidcAuthException on the first call");
+ // the unusable grant must NOT be cached as valid: the next call re-runs the flow and succeeds
+ Assert.assertEquals("ID-2", auth.signIn());
+ Assert.assertEquals("the interactive flow must run twice (failed first, recovered second)", 2, deviceCalls.get());
+ }
+ });
+ }
+
+ private static void assertBuildFails(String deviceEndpoint, String tokenEndpoint, String expectedMessage) {
+ try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
+ .clientId("c")
+ .deviceAuthorizationEndpoint(deviceEndpoint)
+ .tokenEndpoint(tokenEndpoint)
+ .build()
+ ) {
+ Assert.fail("expected build to fail for device=" + deviceEndpoint + " token=" + tokenEndpoint);
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage));
+ }
+ }
+
+ /**
+ * Fails unless NO sink or String reachable from {@code instance} - its own fields, and the fields of any
+ * client object they point at - still carries {@code secret}. A StringSink is read through its whole
+ * backing array, not just up to its write position, because that tail is precisely what a plain clear()
+ * leaves behind.
+ */
+ private static void assertHoldsNowhere(Object instance, String secret) throws Exception {
+ String holder = findHolderOf(instance, secret);
+ Assert.assertNull("close() left \"" + secret + "\" readable in " + holder, holder);
+ }
+
+ private static void assertHoldsSomewhere(Object instance, String secret) throws Exception {
+ Assert.assertNotNull("the state this test is about was never there: no field holds \"" + secret + '"',
+ findHolderOf(instance, secret));
+ }
+
+ /**
+ * Drives one issuer-path case through the PUBLIC path a user takes: a QuestDB {@code /settings} that
+ * advertises {@code devicePath} while the caller pins the issuer to {@code /realms/acme}. Asserts the
+ * outcome a caller sees - fromQuestDB throwing - rather than the return value of the private scan, so a
+ * rename or an inline of that scan leaves the coverage intact. The sibling scenario tests
+ * (testIssuerPathScopingRejectsEncodedSlash and friends) use the same shape; this exists so the encoding
+ * table can stay a table.
+ */
+ private static void assertIssuerScope(String devicePath, boolean accepted) throws Exception {
+ AtomicReference serverRef = new AtomicReference<>();
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ MockOidcServer server = serverRef.get();
+ return MockOidcServer.json(200, "{\"config\":{"
+ + "\"acl.oidc.enabled\":true,"
+ + "\"acl.oidc.client.id\":\"questdb\","
+ + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\","
+ + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(devicePath) + "\""
+ + "}}");
+ };
+ try (MockOidcServer server = new MockOidcServer(handler)) {
+ serverRef.set(server);
+ final String issuer = server.httpUrl("/realms/acme");
+ if (accepted) {
+ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(issuer))) {
+ Assert.assertNotNull("an endpoint genuinely under the issuer path must be accepted: "
+ + devicePath, auth);
+ }
+ } else {
+ assertOidcFails(() -> OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(issuer)),
+ "not under the pinned issuer",
+ "an endpoint that escapes the issuer path must be rejected: " + devicePath);
+ }
+ }
+ }
+
+ private static void assertIssuerScopeAccepts(String devicePath) throws Exception {
+ assertIssuerScope(devicePath, true);
+ }
+
+ private static void assertIssuerScopeRejects(String devicePath) throws Exception {
+ assertIssuerScope(devicePath, false);
+ }
+
+ private static void assertNoControlChars(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ Assert.assertFalse("control char at index " + i + " in '" + value + "'", Character.isISOControl(value.charAt(i)));
+ }
+ }
+
+ private static void assertNoUnsafeDisplayChars(String value) {
+ // mirrors OidcAuthException.isUnsafeForDisplay: no controls, no Cf format chars, no bidi/BOM -
+ // checked per code point so a supplementary-plane (>= U+10000) format/control char is not missed
+ for (int i = 0; i < value.length(); ) {
+ int cp = value.codePointAt(i);
+ boolean unsafe = Character.isISOControl(cp)
+ || Character.getType(cp) == Character.FORMAT
+ || Character.getType(cp) == Character.SURROGATE
+ || (cp >= 0x202A && cp <= 0x202E)
+ || (cp >= 0x2066 && cp <= 0x2069)
+ || cp == 0x200E || cp == 0x200F
+ || cp == 0xFEFF;
+ Assert.assertFalse("display-unsafe char U+" + Integer.toHexString(cp) + " at index " + i + " in '" + value + "'", unsafe);
+ i += Character.charCount(cp);
+ }
+ }
+
+ /**
+ * Asserts that {@code call} - a {@code signIn()}, a {@code getToken()} or a discovery that must not
+ * succeed - throws an {@link OidcAuthException} whose message carries {@code expectedMessage}, and hands
+ * that exception back so a caller with more to check keeps asserting on it. Same idiom as
+ * {@link #assertBuildFails}, applied to the seven-line try/fail/catch this file used to stamp out at
+ * roughly thirty sites.
+ */
+ private static OidcAuthException assertOidcFails(Supplier> call, String expectedMessage) {
+ return assertOidcFails(call, expectedMessage, "the call must not succeed");
+ }
+
+ private static OidcAuthException assertOidcFails(Supplier> call, String expectedMessage, String whatMustFail) {
+ final Object returned;
+ try {
+ returned = call.get();
+ } catch (OidcAuthException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage));
+ return e;
+ }
+ // The call SUCCEEDED. Close what it handed back before failing - a construction that should have been
+ // rejected must not leak past the assertion - then report the value itself, which on a token path IS
+ // the credential an over-permissive check let through.
+ if (returned instanceof AutoCloseable) {
+ try {
+ ((AutoCloseable) returned).close();
+ } catch (Exception ignore) {
+ // the failure below is the one that matters
+ }
+ }
+ throw new AssertionError(whatMustFail + " [expected an OidcAuthException containing \"" + expectedMessage
+ + "\", got " + returned + ']');
+ }
+
+ private static boolean awaitInside(Thread t, String method, long timeoutMillis) throws InterruptedException {
+ // poll the thread's own stack until the named OidcDeviceAuth frame shows up: the only evidence that a
+ // helper thread has actually ENTERED a call, as opposed to having been scheduled at all
+ final long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ if (isInside(t, method)) {
+ return true;
+ }
+ Thread.sleep(10);
+ }
+ return false;
+ }
+
+ private static String deviceAuthorizationJson(int interval, int expiresIn) {
+ return "{"
+ + "\"device_code\":\"DEV-CODE\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"verification_uri_complete\":\"https://verify.example/device?user_code=WDJB-MJHT\","
+ + "\"expires_in\":" + expiresIn + ","
+ + "\"interval\":" + interval
+ + "}";
+ }
+
+ // DiscoveryOptions permitting insecure http with a no-op prompt: tests must never print to the console
+ // or try to open a real browser, which the default prompt now does. The common shape for tests reaching
+ // a plaintext mock server.
+ private static OidcDeviceAuth.DiscoveryOptions insecure() {
+ return new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true).prompt(noopPrompt());
+ }
+
+ @Test(timeout = 30_000)
+ public void testControlCharInUnusedTokenKindDoesNotAbortGrant() throws Exception {
+ assertMemoryLeak(() -> {
+ // groupsInToken=false, so signIn() serves and sends only the access_token; the id_token is
+ // cached but never placed in a header or a PG-wire password. A control char in that unused id_token
+ // must not reject an otherwise-usable grant - only the served kind is validated for wire safety
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ // a clean access_token (the served kind) alongside an id_token carrying a decoded control char
+ return MockOidcServer.json(200, tokenJson("CLEAN-ACCESS", "bad" + jsonUnicodeEscape(0x0001) + "id", null, 3600));
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ Assert.assertEquals("CLEAN-ACCESS", auth.signIn());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception {
+ assertMemoryLeak(() -> {
+ // a real HTTP status is exactly 3 digits; a malformed 1-digit "2" (all digits, so readResponse
+ // accepts it) must not be classified as a 2xx success by its leading digit and accepted as a grant
+ String tokenBody = tokenJson("SHOULD-NOT-ACCEPT", null, null, 3600);
+ String rawToken = "HTTP/1.1 2 OK\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n"
+ + Integer.toHexString(tokenBody.length()) + "\r\n" + tokenBody + "\r\n"
+ + "0\r\n\r\n";
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.raw(rawToken);
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ try {
+ auth.signIn();
+ Assert.fail("expected a malformed 1-digit status to be rejected, not accepted as success");
+ } catch (OidcAuthException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("rejected the request") || msg.contains("refusing to keep polling"));
+ Assert.assertFalse("the unaccepted token must not leak: " + msg, msg.contains("SHOULD-NOT-ACCEPT"));
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testShortAllDigitStatusNotTreatedAsTransientOrTerminal() throws Exception {
+ // a real HTTP status is exactly 3 digits. A malformed 1-digit "5" must not be read as a transient 5xx
+ // (which would poll on to the device-code deadline), nor a 1-digit "4" as a terminal 4xx, by the leading
+ // digit alone; both fall through to the fast terminal reject rather than an infinite poll.
+ for (String shortStatus : new String[]{"5", "4"}) {
+ assertMemoryLeak(() -> {
+ String tokenBody = tokenJson("SHOULD-NOT-ACCEPT", null, null, 3600);
+ String rawToken = "HTTP/1.1 " + shortStatus + " X\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n"
+ + Integer.toHexString(tokenBody.length()) + "\r\n" + tokenBody + "\r\n"
+ + "0\r\n\r\n";
+ MockOidcServer.Handler handler = (method, path, body) -> {
+ if (DEVICE_PATH.equals(path)) {
+ return MockOidcServer.json(200, "{"
+ + "\"device_code\":\"DEV\","
+ + "\"user_code\":\"WDJB-MJHT\","
+ + "\"verification_uri\":\"https://verify.example/device\","
+ + "\"expires_in\":300,"
+ + "\"interval\":1"
+ + "}");
+ }
+ return MockOidcServer.raw(rawToken);
+ };
+ try (MockOidcServer server = new MockOidcServer(handler);
+ OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
+ try {
+ auth.signIn();
+ Assert.fail("expected malformed 1-digit status '" + shortStatus + "' to be rejected fast");
+ } catch (OidcAuthException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("rejected the request") || msg.contains("refusing to keep polling"));
+ // must NOT have polled to the device-code deadline (that would be a mis-classified transient)
+ Assert.assertFalse(msg, msg.contains("device code expired"));
+ Assert.assertFalse("the unaccepted token must not leak: " + msg, msg.contains("SHOULD-NOT-ACCEPT"));
+ }
+ }
+ });
+ }
+ }
+
+ // Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next
+ // signIn()/getToken() takes the silent-refresh (or interactive re-sign-in) path. Reflection
+ // because the field is private and there is no configurable clock skew to lean on anymore; the client is
+ // an open module, so this reaches it without widening production visibility for the test.
+ // package-private, not private: OidcDeviceAuthPersistenceTest needs the same thing and a second copy of
+ // this reflection would be the third in the package. There is no non-reflective route - expires_in is
+ // clamped to a default when non-positive, and the smallest usable value still leaves a live window that
+ // would have to be slept out.
+ static void expireCachedToken(OidcDeviceAuth auth) throws Exception {
+ Field f = OidcDeviceAuth.class.getDeclaredField("expiresAtMillis");
+ f.setAccessible(true);
+ f.setLong(auth, 0L); // any "now" is past 0 minus the (capped, non-negative) skew, so the token reads as expired
+ }
+
+ // Reads the cached token's absolute expiry (epoch millis) so a test can assert the lifetime clamp directly.
+ private static Object readField(Object instance, String name) throws Exception {
+ Field f = instance.getClass().getDeclaredField(name);
+ f.setAccessible(true);
+ return f.get(instance);
+ }
+
+ private static long readExpiresAtMillis(OidcDeviceAuth auth) throws Exception {
+ Field f = OidcDeviceAuth.class.getDeclaredField("expiresAtMillis");
+ f.setAccessible(true);
+ return f.getLong(auth);
+ }
+
+ // isEndpointUnderIssuerPath is a private static security check (it scopes a /settings-advertised endpoint
+ // to the pinned issuer's path); the client is an open module, so reflection reaches it without widening
+ // production visibility for the test
+ /**
+ * Returns a description of the first field holding {@code secret}, or null when none does. Walks the
+ * instance's declared fields and, one level deeper, the fields of any {@code io.questdb.client} object
+ * among them - which is what reaches the sinks inside the two response parsers.
+ */
+ private static String findHolderOf(Object instance, String secret) throws Exception {
+ for (Field f : instance.getClass().getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers())) {
+ continue;
+ }
+ f.setAccessible(true);
+ Object value = f.get(instance);
+ if (value == null) {
+ continue;
+ }
+ if (value instanceof StringSink && sinkContents((StringSink) value).contains(secret)) {
+ return instance.getClass().getSimpleName() + '.' + f.getName();
+ }
+ if (value instanceof String && ((String) value).contains(secret)) {
+ return instance.getClass().getSimpleName() + '.' + f.getName();
+ }
+ if (value != instance && value.getClass().getName().startsWith("io.questdb.client.")
+ && !(value instanceof StringSink)) {
+ for (Field nested : value.getClass().getDeclaredFields()) {
+ if (Modifier.isStatic(nested.getModifiers())) {
+ continue;
+ }
+ nested.setAccessible(true);
+ Object nestedValue = nested.get(value);
+ if (nestedValue instanceof StringSink
+ && sinkContents((StringSink) nestedValue).contains(secret)) {
+ return f.getName() + '.' + nested.getName();
+ }
+ if (nestedValue instanceof String && ((String) nestedValue).contains(secret)) {
+ return f.getName() + '.' + nested.getName();
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ // isLoopbackHost is a private static security classifier (it gates the plaintext-channel MITM pin); the
+ // client is an open module, so reflection reaches it without widening production visibility for the test
+ private static boolean isInside(Thread t, String method) {
+ for (StackTraceElement frame : t.getStackTrace()) {
+ if (OidcDeviceAuth.class.getName().equals(frame.getClassName())
+ && method.equals(frame.getMethodName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // builds a JSON unicode escape (backslash-u-XXXX) for a BMP code point without writing one literally
+ // in this source (char 92 is REVERSE SOLIDUS), so the file stays ASCII; the client's JSON lexer decodes
+ // the escape back into the real character, exercising the same decode-then-display path a hostile IdP hits
+ private static String jsonUnicodeEscape(int codePoint) {
+ String hex = Integer.toHexString(codePoint);
+ return ((char) 92) + "u" + "0000".substring(hex.length()) + hex;
+ }
+
+ /**
+ * The whole backing array of every {@link StringSink} the instance's {@code jsonLexer} owns, past the
+ * write position too - which is where a cleared but unwiped secret survives. Targets the lexer
+ * directly rather than going through findHolderOf, which reports only the FIRST holder it meets and
+ * would name the String field instead while the token was still cached.
+ */
+ private static String lexerBuffers(OidcDeviceAuth auth) throws Exception {
+ Object lexer = readField(auth, "jsonLexer");
+ Assert.assertNotNull("clearCache() must keep the lexer alive; close() is the one that frees it",
+ lexer);
+ StringSink out = new StringSink();
+ for (Field f : lexer.getClass().getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers())) {
+ continue;
+ }
+ f.setAccessible(true);
+ Object value = f.get(lexer);
+ if (value instanceof StringSink) {
+ out.put(f.getName()).put('=').put(sinkContents((StringSink) value)).put(' ');
+ }
+ }
+ return out.toString();
+ }
+
+ private static OidcDeviceAuth newAuth(MockOidcServer server, boolean groupsInToken, DeviceCodePrompt prompt) {
+ return OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(server.httpUrl(TOKEN_PATH))
+ .scope("openid groups")
+ .groupsInToken(groupsInToken)
+ .prompt(prompt)
+ .allowInsecureTransport(true)
+ .build();
+ }
+
+ private static DeviceCodePrompt noopPrompt() {
+ return challenge -> {
+ };
+ }
+
+ private static void parseSplitValue(int cacheSizeLimit, long address, int split, int len) throws JsonException {
+ try (JsonLexer lexer = new JsonLexer(1024, cacheSizeLimit)) {
+ lexer.parse(address, address + split, NOOP_JSON_PARSER);
+ lexer.parse(address + split, address + len, NOOP_JSON_PARSER);
+ lexer.parseLast();
+ }
+ }
+
+ /**
+ * The sink's WHOLE backing array as a String - past the write position too, which is where a cleared but
+ * unwiped secret survives.
+ */
+ // Reads the refresh_token the client actually presented, so a test can tell a rotation from a replay.
+ // The form body is "grant_type=refresh_token&refresh_token=&client_id=...", so the leading '&'
+ // is what separates the parameter from the grant_type value that shares its name.
+ private static String refreshTokenParam(String body) {
+ final String marker = "&refresh_token=";
+ final int at = body.indexOf(marker);
+ if (at < 0) {
+ return "";
+ }
+ final int from = at + marker.length();
+ final int to = body.indexOf('&', from);
+ return to < 0 ? body.substring(from) : body.substring(from, to);
+ }
+
+ private static String sinkContents(StringSink sink) throws Exception {
+ Field buffer = StringSink.class.getDeclaredField("buffer");
+ buffer.setAccessible(true);
+ return new String((char[]) buffer.get(sink));
+ }
+
+ private static String settingsJson(boolean enabled, boolean withDeviceEndpoint, String tokenEndpoint, String deviceEndpoint) {
+ StringSink config = new StringSink();
+ config.put("{\"config\":{");
+ config.put("\"acl.oidc.enabled\":").put(Boolean.toString(enabled)).put(',');
+ config.put("\"acl.oidc.client.id\":\"questdb\",");
+ config.put("\"acl.oidc.scope\":\"openid groups\",");
+ config.put("\"acl.oidc.groups.encoded.in.token\":true,");
+ config.put("\"acl.oidc.token.endpoint\":\"").put(tokenEndpoint).put('"');
+ if (withDeviceEndpoint) {
+ config.put(",\"acl.oidc.device.authorization.endpoint\":\"").put(deviceEndpoint).put('"');
+ }
+ config.put("},\"preferences.version\":0,\"preferences\":{}}");
+ return config.toString();
+ }
+
+ private static String tokenJson(String accessToken, String idToken, String refreshToken, int expiresIn) {
+ StringSink sb = new StringSink();
+ sb.put("{\"token_type\":\"Bearer\",\"expires_in\":").put(expiresIn);
+ if (accessToken != null) {
+ sb.put(",\"access_token\":\"").put(accessToken).put('"');
+ }
+ if (idToken != null) {
+ sb.put(",\"id_token\":\"").put(idToken).put('"');
+ }
+ if (refreshToken != null) {
+ sb.put(",\"refresh_token\":\"").put(refreshToken).put('"');
+ }
+ sb.put('}');
+ return sb.toString();
+ }
+
+ private static String wellKnownJson(String deviceEndpoint, String tokenEndpoint, String issuer) {
+ return "{"
+ + "\"issuer\":\"" + issuer + "\","
+ + "\"authorization_endpoint\":\"" + issuer + "/authorize\","
+ + "\"token_endpoint\":\"" + tokenEndpoint + "\","
+ + "\"device_authorization_endpoint\":\"" + deviceEndpoint + "\""
+ + "}";
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTransportBudgetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTransportBudgetTest.java
new file mode 100644
index 000000000..7e238dec3
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTransportBudgetTest.java
@@ -0,0 +1,98 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.auth;
+
+import io.questdb.client.HttpClientConfiguration;
+import io.questdb.client.cutlass.auth.OidcDeviceAuth;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+
+/**
+ * Pins that the HTTP clients {@link OidcDeviceAuth} builds take their CONNECTION budgets from
+ * {@code httpTimeoutMillis}, not from the transport defaults.
+ *
+ * {@code HttpClient.connect} reads both: it leaves the TCP connect to the OS when the connect timeout is 0,
+ * and it sizes the TLS handshake as {@code connectTimeout > 0 ? connectTimeout : defaultTimeout}. Taking
+ * {@code DefaultHttpClientConfiguration.INSTANCE} - 0 and 600s - therefore gave the handshake alone a 600s
+ * budget derived from nothing the caller set, so neither {@code MAX_HTTP_TIMEOUT_MILLIS} (120s) nor
+ * {@code Builder.build()}'s {@code lockStaleMillis} floor constrained it.
+ *
+ * The consequence is not a slow request. A silent refresh runs inside {@code FileTokenStore}'s cross-process
+ * lock, whose file is stamped once at creation and never re-stamped, so a hold outrunning
+ * {@code DEFAULT_LOCK_STALE_MILLIS} (600s) is judged abandoned and stolen by a peer. Both holders then POST
+ * the same rotating refresh token, and an identity provider with reuse detection revokes the whole family -
+ * on a headless producer, ingestion stops until a human re-runs the device flow.
+ *
+ * Asserted on the configuration rather than end to end because a real TLS handshake stall needs a
+ * certificate, and the client's test tree has none - the TLS fixture ({@code TlsProxyRule}) lives in the
+ * Enterprise tree, where {@code OidcDeviceAuthTlsTest} drives the flow over a real TLS socket. Reflection
+ * because every test class here is in {@code io.questdb.client.test.*}, so a package-private hook would not
+ * be reachable either; {@code FileTokenStoreTest} reaches this class's private statics the same way.
+ */
+public class OidcDeviceAuthTransportBudgetTest {
+
+ @Test
+ public void testConnectionBudgetsDeriveFromTheHttpTimeout() throws Exception {
+ final Method httpConfig = OidcDeviceAuth.class.getDeclaredMethod("httpConfig", int.class);
+ httpConfig.setAccessible(true);
+
+ // A value distinct from every default in play (0, 30_000, 600_000), so a config that quietly fell
+ // back to any of them fails rather than coincidentally matching.
+ final int timeoutMillis = 7_777;
+ final HttpClientConfiguration config = (HttpClientConfiguration) httpConfig.invoke(null, timeoutMillis);
+
+ Assert.assertEquals("the TLS handshake budget is connectTimeout when it is positive, so leaving this "
+ + "at 0 hands the handshake the 600s request-timeout default instead",
+ timeoutMillis, config.getConnectTimeout());
+ Assert.assertEquals("the request timeout must be the caller's figure, not the 600s default",
+ timeoutMillis, config.getTimeout());
+
+ // Guard the premise: a zero connect timeout is precisely what routes HttpClient.connect to the OS
+ // for the TCP connect and to defaultTimeout for the handshake, so a regression to the shared
+ // DefaultHttpClientConfiguration.INSTANCE reads as 0 here.
+ Assert.assertTrue("a positive connect timeout is what bounds both the TCP connect and the TLS "
+ + "handshake; 0 restores the unbounded shape", config.getConnectTimeout() > 0);
+ }
+
+ @Test
+ public void testDiscoveryClientsCarryTheSameDerivedBudgets() throws Exception {
+ // Discovery runs before an instance exists, so it cannot take a builder value - but it reads
+ // /settings and .well-known from the same untrusted network position and must be bounded too.
+ final java.lang.reflect.Field discoveryConfig = OidcDeviceAuth.class.getDeclaredField("DISCOVERY_HTTP_CONFIG");
+ discoveryConfig.setAccessible(true);
+ final HttpClientConfiguration config = (HttpClientConfiguration) discoveryConfig.get(null);
+
+ final java.lang.reflect.Field defaultTimeout = OidcDeviceAuth.class.getDeclaredField("DEFAULT_HTTP_TIMEOUT_MILLIS");
+ defaultTimeout.setAccessible(true);
+ final int expected = (Integer) defaultTimeout.get(null);
+
+ Assert.assertEquals("discovery's connect/TLS budget must be the default HTTP timeout, not 0",
+ expected, config.getConnectTimeout());
+ Assert.assertEquals("discovery's request timeout must be the default HTTP timeout, not 600s",
+ expected, config.getTimeout());
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java
index 6bd20a94d..beda163d5 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java
@@ -26,6 +26,7 @@
import io.questdb.client.cutlass.http.client.AbstractChunkedResponse;
import io.questdb.client.cutlass.http.client.Fragment;
+import io.questdb.client.cutlass.http.client.HttpClientException;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Numbers;
import io.questdb.client.std.ObjList;
@@ -186,6 +187,185 @@ public void testFuzz() {
createChunks(rnd, encoded.toString(), fragCount));
}
+ @Test(timeout = 30_000)
+ public void testNoArgRecvHonoursPositiveDefaultTimeout() {
+ // The ILP flush path reads a chunked response via the no-arg recv(), which delegates to
+ // recv(defaultTimeout). With a positive defaultTimeout (the production HttpClient timeout) the
+ // whole-call bound applies on that path too, so a server dribbling a never-terminated chunk size
+ // cannot wedge a single recv() past the timeout. The explicit recv(int) path is covered by
+ // testRecvHonoursTotalTimeoutWhileChunkSizeDribbles.
+ final long memSize = 64;
+ final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT);
+ try {
+ final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, 50) { // positive default
+ @Override
+ protected int recvOrDie(long bufLo, long bufHi, int timeout) {
+ if (bufLo >= bufHi) {
+ return 0; // buffer full of a CRLF-less chunk size: no forward progress
+ }
+ Unsafe.getUnsafe().putByte(bufLo, (byte) '0'); // a hex digit, never the terminating CR
+ return 1;
+ }
+ };
+ rsp.begin(mem, mem);
+ try {
+ rsp.recv(); // no-arg: delegates to recv(defaultTimeout=50)
+ Assert.fail("expected the no-arg recv to time out on a dribbled, never-terminated chunk size");
+ } catch (HttpClientException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out"));
+ }
+ } finally {
+ Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testOverflowingChunkSizeIsRejectedRatherThanSpun() {
+ // A chunk-size line of 16 or more hex digits overflows an unchecked val << 4 accumulation, and the
+ // residue decides how the damage shows up. All three must be rejected; only the first one was.
+ //
+ // 8000000000000000 -> NEGATIVE. Matches neither the "size > 0" data branch nor the "size == 0"
+ // terminator, so the state machine breaks straight back to the top of the
+ // loop. The preceding chunk left receive == false with bytes still buffered,
+ // so the read gate is skipped too and the loop spins with nothing to stop it.
+ // 10000000000000000 -> ZERO, and 10000000000000001 -> 1; both report success with the wrong
+ // bytes, and get their own tests below. Rejecting only the negative case
+ // left those two, which are the dangerous ones: a spin is at least visible,
+ // whereas truncated JSON parses.
+ //
+ // defaultTimeout is -1 on purpose, so no deadline can rescue the spinning case and the test passes
+ // only because the size itself is rejected. The server chooses that size line, and for a discovery
+ // or token response that server is untrusted.
+ // a trailing byte keeps dataLo < dataHi so the read gate stays shut and the spin is reachable
+ assertChunkSizeRejected("8000000000000000", "X", "negative overflow residue");
+ }
+
+ @Test(timeout = 30_000)
+ public void testZeroWrappingChunkSizeIsRejectedRatherThanTruncating() {
+ // 10000000000000000 wraps to ZERO, which the state machine reads as the terminal chunk: recv()
+ // returns null and the caller sees a complete-looking body that is actually truncated. Worse than
+ // the spin the negative residue causes, because nothing looks wrong -- truncated JSON parses, and
+ // the connection's framing is lost for the next keep-alive response on it. The size line is chosen
+ // by the server, untrusted for an OIDC discovery or token response.
+ // A proper CRLF terminator here, so the pre-fix parser really does complete the body rather than
+ // stall waiting for one.
+ assertChunkSizeRejected("10000000000000000", "\r\n", "zero overflow residue");
+ }
+
+ @Test(timeout = 30_000)
+ public void testPositiveWrappingChunkSizeIsRejectedRatherThanMisframing() {
+ // 10000000000000001 wraps to 1: a one-byte data chunk that frames the following bytes as chunk
+ // furniture. Like the zero residue this reports success, just with the wrong bytes.
+ assertChunkSizeRejected("10000000000000001", "X", "positive overflow residue");
+ }
+
+ @Test
+ public void testZeroPaddedChunkSizeIsStillAccepted() {
+ // The guard counts SIGNIFICANT hex digits, so a server that pads its size line is not mistaken for
+ // one overflowing it. A raw length check would reject this - it is 20 characters against a 15-digit
+ // bound - and would break framing against a perfectly conformant peer, which is a worse failure
+ // than the one the bound exists to prevent.
+ final long memSize = 128;
+ final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT);
+ try {
+ final String wire = "00000000000000000001\r\nZ\r\n0\r\n\r\n";
+ final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, -1) {
+ boolean delivered;
+
+ @Override
+ protected int recvOrDie(long bufLo, long bufHi, int timeout) {
+ if (delivered) {
+ return 0;
+ }
+ delivered = true;
+ for (int i = 0; i < wire.length(); i++) {
+ Unsafe.getUnsafe().putByte(bufLo + i, (byte) wire.charAt(i));
+ }
+ return wire.length();
+ }
+ };
+ rsp.begin(mem, mem);
+ Fragment first = rsp.recv();
+ Assert.assertNotNull("a zero-padded size line must frame its chunk normally", first);
+ Assert.assertEquals('Z', (char) Unsafe.getUnsafe().getByte(first.lo()));
+ Assert.assertEquals(1, first.hi() - first.lo());
+ Assert.assertNull("and the terminator must still terminate", rsp.recv());
+ } finally {
+ Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ private static void assertChunkSizeRejected(String sizeLine, String tail, String what) {
+ final long memSize = 128;
+ final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT);
+ try {
+ // one well-formed chunk (leaves receive == false), then the overflowing size line, then a
+ // trailing byte so dataLo < dataHi holds the read gate shut
+ final String wire = "1\r\nA\r\n" + sizeLine + "\r\n" + tail;
+ final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, -1) {
+ boolean delivered;
+
+ @Override
+ protected int recvOrDie(long bufLo, long bufHi, int timeout) {
+ if (delivered) {
+ return 0;
+ }
+ delivered = true;
+ for (int i = 0; i < wire.length(); i++) {
+ Unsafe.getUnsafe().putByte(bufLo + i, (byte) wire.charAt(i));
+ }
+ return wire.length();
+ }
+ };
+ rsp.begin(mem, mem);
+ Fragment first = rsp.recv();
+ Assert.assertNotNull(what + ": the first chunk must still be delivered", first);
+ Assert.assertEquals('A', (char) Unsafe.getUnsafe().getByte(first.lo()));
+ try {
+ Fragment second = rsp.recv();
+ Assert.fail(what + ": expected the overflowing chunk size to be rejected as malformed, got "
+ + (second == null ? "a terminal chunk (a truncated body reported as complete)"
+ : "a data chunk"));
+ } catch (HttpClientException e) {
+ Assert.assertTrue(what + ": " + e.getMessage(),
+ e.getMessage().contains("malformed chunk size"));
+ }
+ } finally {
+ Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testRecvHonoursTotalTimeoutWhileChunkSizeDribbles() {
+ // a server that dribbles the chunk-size line and never sends its terminating CRLF must not keep a
+ // single recv() running past its timeout. recv(timeout) bounds the whole call (not each socket read),
+ // so the loop scanning the never-terminated chunk size aborts once the timeout elapses. Without the
+ // bound this recv() never returns and the @Test timeout fires instead.
+ final long memSize = 64;
+ final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT);
+ try {
+ final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, -1) {
+ @Override
+ protected int recvOrDie(long bufLo, long bufHi, int timeout) {
+ if (bufLo >= bufHi) {
+ return 0; // buffer full of a CRLF-less chunk size: no forward progress
+ }
+ Unsafe.getUnsafe().putByte(bufLo, (byte) '0'); // a hex digit, never the terminating CR
+ return 1;
+ }
+ };
+ rsp.begin(mem, mem);
+ try {
+ rsp.recv(50);
+ Assert.fail("expected recv to time out on a dribbled, never-terminated chunk size");
+ } catch (HttpClientException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out"));
+ }
+ } finally {
+ Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
@Test
public void testSingleFragment() {
String[] fragments = {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientConstructorLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientConstructorLeakTest.java
new file mode 100644
index 000000000..4670d5077
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientConstructorLeakTest.java
@@ -0,0 +1,212 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.http.client;
+
+import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.HttpClientConfiguration;
+import io.questdb.client.cutlass.http.client.HttpClient;
+import io.questdb.client.cutlass.http.client.HttpClientFactory;
+import io.questdb.client.network.EpollFacade;
+import io.questdb.client.network.EpollFacadeImpl;
+import io.questdb.client.network.KqueueFacade;
+import io.questdb.client.network.KqueueFacadeImpl;
+import io.questdb.client.network.NetworkFacade;
+import io.questdb.client.network.SelectFacade;
+import io.questdb.client.std.Os;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * A constructor that fails partway leaves an object nobody can close. It never reaches the caller, so no
+ * {@code finally}, no try-with-resources and no {@code close()} ever runs on it, and whatever it had already
+ * taken is lost for the life of the process.
+ *
+ * {@link HttpClient}'s base constructor takes a socket and two native buffers, then each platform subclass
+ * builds its poller. A poller that fails to initialise therefore stranded all of that. What makes it worth
+ * guarding is the trigger: {@code epoll_create}/{@code kqueue} fail on fd exhaustion, and the two mallocs
+ * fail under memory pressure, so the failure arrives exactly when resources are already scarce - and a
+ * caller that retries compounds the loss each time. OIDC discovery newly exposes it by building a client per
+ * fetch.
+ *
+ * One test covers the base constructor's own staging and runs everywhere; the other three cover the poller,
+ * and only the one matching the running platform executes. All assert through {@code assertMemoryLeak} that
+ * nothing survives the throw. The injection differs because the clean failure point does: epoll and kqueue
+ * are reached through a facade, so a facade returning a negative descriptor mimics fd exhaustion without
+ * touching real descriptors, while FDSet and the base buffers have no facade and take a failing size
+ * instead. Removing the rollback leaks 131072 bytes on the poller path and 65536 on the base path.
+ *
+ * Only the base test and the platform test for the developing machine can be run locally; the other two
+ * platforms' tests are exercised by CI.
+ */
+public class HttpClientConstructorLeakTest {
+
+ @Test
+ public void testEpollCreateFailureLeaksNothing() throws Exception {
+ Assume.assumeTrue("epoll is the Linux poller", Os.type == Os.LINUX);
+ assertMemoryLeak(() -> assertConstructionFailureLeaksNothing(new DefaultHttpClientConfiguration() {
+ @Override
+ public EpollFacade getEpollFacade() {
+ return new EpollFacade() {
+ @Override
+ public int epollCreate() {
+ return -1; // as on fd exhaustion
+ }
+
+ @Override
+ public int epollCtl(int epfd, int op, int fd, long eventPtr) {
+ return EpollFacadeImpl.INSTANCE.epollCtl(epfd, op, fd, eventPtr);
+ }
+
+ @Override
+ public int epollWait(int epfd, long eventPtr, int eventCount, int timeout) {
+ return EpollFacadeImpl.INSTANCE.epollWait(epfd, eventPtr, eventCount, timeout);
+ }
+
+ @Override
+ public int errno() {
+ return 24; // EMFILE
+ }
+
+ @Override
+ public NetworkFacade getNetworkFacade() {
+ return EpollFacadeImpl.INSTANCE.getNetworkFacade();
+ }
+ };
+ }
+ }));
+ }
+
+ @Test
+ public void testKqueueCreateFailureLeaksNothing() throws Exception {
+ Assume.assumeTrue("kqueue is the BSD/macOS poller", Os.type == Os.DARWIN || Os.type == Os.FREEBSD);
+ assertMemoryLeak(() -> assertConstructionFailureLeaksNothing(new DefaultHttpClientConfiguration() {
+ @Override
+ public KqueueFacade getKQueueFacade() {
+ return new KqueueFacade() {
+ @Override
+ public NetworkFacade getNetworkFacade() {
+ return KqueueFacadeImpl.INSTANCE.getNetworkFacade();
+ }
+
+ @Override
+ public int kevent(int kq, long changeList, int nChanges, long eventList, int nEvents, int timeout) {
+ return KqueueFacadeImpl.INSTANCE.kevent(kq, changeList, nChanges, eventList, nEvents, timeout);
+ }
+
+ @Override
+ public int kqueue() {
+ return -1; // as on fd exhaustion
+ }
+ };
+ }
+ }));
+ }
+
+ @Test
+ public void testBaseConstructorFailureLeaksNothing() throws Exception {
+ // The base constructor's OWN staging, independent of any platform poller: it takes a socket, then the
+ // request buffer, then the response-parser buffer, then hands the last one to ResponseHeaders. A
+ // failure at any step must not strand the earlier ones. A negative response-buffer size makes the
+ // second malloc fail while the first has already succeeded - the shape a real allocation failure
+ // takes under memory pressure - and it needs no platform-specific injection point.
+ assertMemoryLeak(() -> assertConstructionFailureLeaksNothing(new DefaultHttpClientConfiguration() {
+ @Override
+ public int getResponseBufferSize() {
+ return -1;
+ }
+ }));
+ }
+
+ @Test
+ public void testSelectFacadeFailureLeaksNothingIncludingTheFdSet() throws Exception {
+ Assume.assumeTrue("select/FDSet is the Windows poller", Os.type == Os.WINDOWS);
+ // The OTHER arm of the Windows guard, and the one the sibling above cannot reach: here FDSet is
+ // constructed successfully and the throw lands on the next statement, so the guard has to free the
+ // FDSet as well as everything the base constructor took. getSelectFacade() is a caller-supplied
+ // extension point evaluated inside the try for exactly this reason, and until now nothing drove it.
+ // Deterministic, with no arithmetic to rot.
+ assertMemoryLeak(() -> assertConstructionFailureLeaksNothing(new DefaultHttpClientConfiguration() {
+ @Override
+ public SelectFacade getSelectFacade() {
+ throw new IllegalStateException("injected select facade failure");
+ }
+ }));
+ }
+
+ @Test
+ public void testSelectFdSetFailureLeaksNothing() throws Exception {
+ Assume.assumeTrue("select/FDSet is the Windows poller", Os.type == Os.WINDOWS);
+ // FDSet reaches no facade, so the injection is its size instead: the constructor computes
+ // ARRAY_OFFSET + 8 * capacity in INT arithmetic, and a capacity that overflows it negative makes
+ // allocateMemory reject the size. An allocation that simply fails is the shape a real one takes
+ // under memory pressure, and FDSet throwing rather than the statement after it is what exercises
+ // the guard's null-tolerant Misc.free(fdSet).
+ //
+ // The capacity has to overflow to a LARGE negative, not merely a negative. Integer.MAX_VALUE - the
+ // obvious choice, and what this used - makes 8 * capacity exactly -8, so the size works out to
+ // ARRAY_OFFSET - 8: negative only where ARRAY_OFFSET is 0 or 4. On Windows fd_set is
+ // { u_int fd_count; SOCKET fd_array[]; } with an 8-byte SOCKET, so arrayOffset() reports 8, the
+ // size lands on exactly 0, and allocateMemory(0) succeeds and hands back a null pointer instead of
+ // failing - construction completed and the test asserted nothing. 1 << 28 makes 8 * capacity
+ // overflow to exactly Integer.MIN_VALUE, so the size is negative whatever arrayOffset() reports.
+ assertMemoryLeak(() -> assertConstructionFailureLeaksNothing(new DefaultHttpClientConfiguration() {
+ @Override
+ public int getWaitQueueCapacity() {
+ return 1 << 28;
+ }
+ }));
+ }
+
+ private static void assertConstructionFailureLeaksNothing(HttpClientConfiguration configuration) {
+ HttpClient client = null;
+ // The "it threw" assertion CANNOT be an Assert.fail() inside the try: fail() throws AssertionError,
+ // which the catch below swallows, so an injection that stopped failing would report a green test
+ // having injected nothing - and all four tests share this helper, so all four would go green at once.
+ // The catch has to stay this broad, which is why the flag is needed rather than a narrower catch: the
+ // four injections share no supertype below Throwable. Epoll and Kqueue throw NetworkError, which
+ // extends Error, while the two failing allocations throw IllegalArgumentException out of
+ // Unsafe.malloc.
+ boolean threw = false;
+ try {
+ client = HttpClientFactory.newPlainTextInstance(configuration);
+ } catch (Throwable expected) {
+ // the point of the test is what assertMemoryLeak checks around it: the socket and the two native
+ // buffers the base constructor took must not survive a throw from the subclass
+ threw = true;
+ } finally {
+ // defensive: if construction unexpectedly succeeded, do not leak it out of the test
+ if (client != null) {
+ client.close();
+ }
+ }
+ Assert.assertTrue(
+ "construction succeeded, so this test's injected failure no longer fires and it proved nothing",
+ threw
+ );
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientRequestTrimTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientRequestTrimTest.java
new file mode 100644
index 000000000..748e72b9a
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientRequestTrimTest.java
@@ -0,0 +1,103 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.http.client;
+
+import io.questdb.client.cutlass.http.client.HttpClient;
+import io.questdb.client.cutlass.http.client.HttpClientFactory;
+import org.junit.Assert;
+import org.junit.Test;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Pins {@code Request.trimContentToLen}'s sentinel guard, whose absence is a SIGSEGV rather than a failed
+ * assertion.
+ *
+ * {@code newRequest()} sets {@code contentStart = -1} and only {@code withContent()} replaces it with a real
+ * address, so a request still at the header stage has no content section. Trimming one anyway computes
+ * {@code -1 + contentLen} as the write pointer, and the next write to the buffer takes the process down -
+ * surefire reports "The forked VM terminated without properly saying goodbye", with no test named.
+ *
+ * That state is ordinary rather than exotic: an ILP request built with an {@code httpTokenProvider} defers
+ * {@code withContent()} until the first row stamps the Authorization header, so it sits at the header stage
+ * between every flush and the next row - which is when {@code cancelRow()} can arrive. {@code cancelRow()}
+ * used to carry a second {@code isTokenPending} check of its own that returned before the trim. The two were
+ * mutually masking: removing either alone left the whole suite green, and only removing both crashed, so
+ * neither was pinned and either could have been dropped by a refactor with CI green. The caller-side one is
+ * gone; this pins the one that remains, which is also the only protection an external caller of this
+ * exported method has.
+ *
+ * Asserted on the pointer rather than by writing through it: an assertion names what broke, where a write
+ * would just kill the fork.
+ */
+public class HttpClientRequestTrimTest {
+
+ @Test
+ public void testTrimContentToLenOnAHeaderStageRequestLeavesThePointerValid() throws Exception {
+ assertMemoryLeak(() -> {
+ try (HttpClient client = HttpClientFactory.newPlainTextInstance()) {
+ // header stage only - no withContent(), so contentStart is still the -1 sentinel. No socket
+ // is involved: newRequest() just resets the buffer and the request state.
+ HttpClient.Request request = client.newRequest("127.0.0.1", 9000);
+ request.GET().url("/write").header("Authorization", "Bearer GOODTOKEN");
+
+ Assert.assertEquals("precondition: no content section, so no content length",
+ 0, request.getContentLength());
+ Assert.assertEquals("precondition: and getContentStart() reports 0, not the sentinel",
+ 0, request.getContentStart());
+ final long ptrAfterHeaders = request.getPtr();
+ Assert.assertTrue("precondition: the headers advanced the write pointer",
+ ptrAfterHeaders > 0);
+
+ // what cancelRow() does on a row that never started
+ request.trimContentToLen(0);
+ Assert.assertEquals("trimming a request with no content section must not move the write "
+ + "pointer - contentStart is -1, so the arithmetic yields an invalid "
+ + "pointer the next write segfaults on",
+ ptrAfterHeaders, request.getPtr());
+
+ // and with a stale non-zero bookmark, which is what rowBookmark holds from the previous
+ // request until stampTokenIfPending resets it
+ request.trimContentToLen(37);
+ Assert.assertEquals("a stale non-zero bookmark must not move it either",
+ ptrAfterHeaders, request.getPtr());
+
+ // the request is still usable afterwards: the content section opens where it should, and
+ // writing through it does not touch a rewound pointer
+ request.withContent();
+ final long contentStart = request.getContentStart();
+ Assert.assertTrue("withContent() must open a real content section", contentStart > 0);
+ request.putAscii("t v=1i\n");
+ Assert.assertEquals(7, request.getContentLength());
+
+ // now that a content section exists, the trim is a real rewind rather than a no-op
+ request.trimContentToLen(0);
+ Assert.assertEquals("with a content section, trimming must actually rewind",
+ 0, request.getContentLength());
+ Assert.assertEquals(contentStart, request.getPtr());
+ }
+ });
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientResponseHeadTimeoutTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientResponseHeadTimeoutTest.java
new file mode 100644
index 000000000..f24d2b191
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientResponseHeadTimeoutTest.java
@@ -0,0 +1,95 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.http.client;
+
+import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.HttpClientConfiguration;
+import io.questdb.client.cutlass.http.client.HttpClient;
+import io.questdb.client.cutlass.http.client.HttpClientException;
+import io.questdb.client.cutlass.http.client.HttpClientFactory;
+import io.questdb.client.test.cutlass.auth.MockOidcServer;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Pins the whole-call bound on the response HEAD read, {@code ResponseHeaders.await(int)}.
+ *
+ * The body reads got this bound first ({@code AbstractResponse.recv}, {@code AbstractChunkedResponse.recv});
+ * the head read kept re-arming the full timeout on every pass. That is not a slower version of the same
+ * thing, it is unbounded: {@code recvOrDie} returns 0 whenever a read yields no application bytes, a 0
+ * leaves {@code totalBytesReceived} unmoved, and an unmoved counter neither advances the header parser nor
+ * fills its buffer - so the "header is too large" escape never fires either.
+ *
+ * It matters because {@code OidcDeviceAuth} reads this head from an identity provider on the
+ * {@code getToken()} path, which an ILP sender built with {@code httpTokenProvider} calls once per flush.
+ * The IdP endpoints are required to be {@code https}, and a partial TLS record decrypting to no application
+ * bytes is exactly the 0-length read above.
+ *
+ * Driven over plaintext with a head dribbled a byte at a time rather than with a stubbed {@code recvOrDie}:
+ * the point is the elapsed-time bound a caller asked for, and a dribbling peer defeats it the same way.
+ */
+public class HttpClientResponseHeadTimeoutTest {
+
+ @Test(timeout = 30_000)
+ public void testAwaitHonoursTotalTimeoutWhileTheHeadDribbles() throws Exception {
+ // 500ms against a head dribbled at 50ms/byte: the bound must fire in ~500ms. Without it every read
+ // makes progress inside its own re-armed 500ms, so await() runs for (bytes x 50ms) and the @Test
+ // timeout fires instead of this assertion.
+ final int timeoutMillis = 500;
+ final HttpClientConfiguration config = new DefaultHttpClientConfiguration() {
+ @Override
+ public int getTimeout() {
+ return timeoutMillis;
+ }
+ };
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.dribbleHead())) {
+ try (HttpClient client = HttpClientFactory.newPlainTextInstance(config)) {
+ HttpClient.Request request = client.newRequest("127.0.0.1", server.port())
+ .GET()
+ .url("/head");
+ final HttpClient.ResponseHeaders headers = request.send(timeoutMillis);
+ final long startNanos = System.nanoTime();
+ try {
+ headers.await(timeoutMillis);
+ Assert.fail("expected await to time out while the response head dribbled");
+ } catch (HttpClientException e) {
+ // Either terminator is the bound working. Against a peer that dribbles, the shrinking
+ // per-pass budget starves ioWait's poll first, so the throw comes from there
+ // ("timed out [errno=..]"); against one whose reads yield no application bytes at all -
+ // the partial-TLS-record case, which consumes no budget - the loop's own deadline check
+ // fires instead ("timed out reading the response head"). What neither can do is keep
+ // running, which is what the elapsed assertion below pins.
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out"));
+ }
+ final long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ // The ceiling is the assertion that matters: it is what a per-read re-arm cannot satisfy.
+ // Generous against the 500ms budget so a loaded CI box does not turn a real bound into a
+ // red test, and still an order of magnitude below the unbounded behaviour.
+ Assert.assertTrue("await must abort on its own deadline, not run on with the dribble; took "
+ + elapsedMillis + "ms against a " + timeoutMillis + "ms budget", elapsedMillis < 10_000);
+ }
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java
index 6c9901db8..2867c27d0 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java
@@ -27,6 +27,7 @@
import io.questdb.client.cutlass.http.client.AbstractResponse;
import io.questdb.client.cutlass.http.client.Fragment;
+import io.questdb.client.cutlass.http.client.HttpClientException;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Os;
import io.questdb.client.std.Unsafe;
@@ -37,6 +38,34 @@
public class ResponseTest {
+ @Test(timeout = 30_000)
+ public void testNoArgRecvHonoursPositiveDefaultTimeout() {
+ // The ILP flush path reads via the no-arg recv(), which delegates to recv(defaultTimeout). With a
+ // positive defaultTimeout (the production HttpClient timeout) the whole-call bound applies on that
+ // path too, so a server yielding no application bytes cannot wedge a single recv() past the timeout.
+ // The explicit recv(int) path is covered by testRecvHonoursTotalTimeoutWhenNoApplicationBytesArrive.
+ final long memSize = 64;
+ final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT);
+ try {
+ final AbstractResponse rsp = new AbstractResponse(mem, mem + memSize, 50) { // positive defaultTimeout
+ @Override
+ protected int recvOrDie(long bufLo, long bufHi, int timeout) {
+ Os.sleep(1); // a readability wakeup that decrypts to no application bytes
+ return 0;
+ }
+ };
+ rsp.begin(mem, mem, 16); // content length 16, nothing received yet
+ try {
+ rsp.recv(); // no-arg: delegates to recv(defaultTimeout=50)
+ Assert.fail("expected the no-arg recv to time out under a positive default timeout");
+ } catch (HttpClientException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out"));
+ }
+ } finally {
+ Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
@Test
public void testNoSplit() {
String[] expectedFragments = {
@@ -48,6 +77,36 @@ public void testNoSplit() {
assertResponse(expectedFragments, actualFragments);
}
+ @Test(timeout = 30_000)
+ public void testRecvHonoursTotalTimeoutWhenNoApplicationBytesArrive() {
+ // A Content-Length response whose socket reads yield no application bytes - e.g. an incomplete or
+ // empty TLS record over a hostile or MITM'd link, where JavaTlsClientSocket.recv returns 0 on
+ // BUFFER_UNDERFLOW without a disconnect - must not keep a single recv() running past its timeout.
+ // recv(timeout) bounds the whole call, not each socket read, so the while (len == 0) loop aborts
+ // once the timeout elapses. Without the bound this recv() never returns and the @Test timeout
+ // fires instead.
+ final long memSize = 64;
+ final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT);
+ try {
+ final AbstractResponse rsp = new AbstractResponse(mem, mem + memSize, -1) {
+ @Override
+ protected int recvOrDie(long bufLo, long bufHi, int timeout) {
+ Os.sleep(1); // a readability wakeup that decrypts to no application bytes
+ return 0;
+ }
+ };
+ rsp.begin(mem, mem, 16); // content length 16, nothing received yet
+ try {
+ rsp.recv(50);
+ Assert.fail("expected recv to time out when no application bytes arrive");
+ } catch (HttpClientException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out"));
+ }
+ } finally {
+ Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
@Test
public void testSplit1() {
String[] expectedFragments = {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java
index 2e8cd96ee..7ec005dce 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java
@@ -24,14 +24,17 @@
package io.questdb.client.test.cutlass.json;
+import io.questdb.client.Sender;
import io.questdb.client.cutlass.json.JsonException;
import io.questdb.client.cutlass.json.JsonLexer;
import io.questdb.client.cutlass.json.JsonParser;
+import io.questdb.client.cutlass.line.http.AbstractLineHttpSender;
import io.questdb.client.std.Files;
import io.questdb.client.std.IntStack;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Mutable;
import io.questdb.client.std.Unsafe;
+import io.questdb.client.std.str.StringSink;
import io.questdb.client.test.tools.TestUtils;
import org.junit.AfterClass;
import org.junit.Assert;
@@ -243,7 +246,9 @@ public void testNestedObjects() throws Exception {
@Test
public void testQuoteEscape() throws Exception {
- assertThat("{\"x\":\"a\\\"bc\"}", "{\"x\": \"a\\\"bc\"}");
+ // the lexer decodes the escaped quote: the value a\"bc becomes a"bc (the assembling parser does
+ // not re-escape, so the decoded quote shows bare in the re-serialized form)
+ assertThat("{\"x\":\"a\"bc\"}", "{\"x\": \"a\\\"bc\"}");
}
@Test
@@ -663,6 +668,185 @@ public void testWrongQuote() {
assertError("Unexpected symbol", 10, "{\"x\": \"a\"bc\",}");
}
+ @Test
+ public void testStringEscapesAreDecoded() throws Exception {
+ assertMemoryLeak(() -> {
+ // JSON string escapes must be resolved, not handed back to the listener literally
+ assertDecodedValue("{\"v\":\"https:\\/\\/h\\/p\"}", "https://h/p"); // escaped slash -> slash
+ assertDecodedValue("{\"v\":\"a\\\"b\"}", "a\"b"); // escaped quote -> quote
+ assertDecodedValue("{\"v\":\"a\\\\b\"}", "a\\b"); // escaped backslash -> backslash
+ assertDecodedValue("{\"v\":\"X\\u0041Y\"}", "XAY"); // 4-hex unicode escape decoded
+ assertDecodedValue("{\"v\":\"X\\u0041\"}", "XA"); // \\uXXXX at end of value (i+6==n boundary)
+ assertDecodedValue("{\"v\":\"tab\\tend\"}", "tab\tend"); // escaped tab -> tab
+ assertDecodedValue("{\"v\":\"plain\"}", "plain"); // no escapes (fast path)
+ });
+ }
+
+ @Test
+ public void testStringEscapesDecodedAcrossSplitParseCalls() throws Exception {
+ assertMemoryLeak(() -> {
+ // a value whose backslash escape straddles two parse() calls (a real HTTP-fragment boundary)
+ // must still be decoded: the "saw a backslash" flag that gates the unescape pass has to persist
+ // across the calls, not reset to false at the start of the second one
+ String json = "{\"v\":\"ab\\ncd\"}"; // value ab\ncd -> abcd
+ int len = json.length();
+ long address = TestUtils.toMemory(json);
+ StringSink captured = new StringSink();
+ JsonParser parser = (code, tag, position) -> {
+ if (code == JsonLexer.EVT_VALUE) {
+ captured.clear();
+ captured.put(tag);
+ }
+ };
+ try (JsonLexer lexer = new JsonLexer(4, 1024)) {
+ // split immediately after the backslash, so the escape's '\' is in the first chunk and the
+ // 'n' it escapes is in the second
+ int split = json.indexOf('\\') + 1;
+ lexer.parse(address, address + split, parser);
+ lexer.parse(address + split, address + len, parser);
+ lexer.parseLast();
+ TestUtils.assertEquals("ab\ncd", captured);
+ } finally {
+ Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testUnicodeEscapeDecodedAcrossSplitParseCalls() throws Exception {
+ assertMemoryLeak(() -> {
+ // a backslash-u-XXXX escape whose four hex digits straddle two parse() calls (a real HTTP-fragment
+ // boundary) must still decode to one character: the lexer stashes the partial value and resolves the
+ // escape only once the whole value is assembled, so parseHex4 never sees a truncated escape
+ String bs = String.valueOf((char) 92); // a single backslash, built without a literal escape
+ String json = "{\"v\":\"x" + bs + "u0041y\"}"; // value x then the escape for A then y -> xAy
+ int len = json.length();
+ long address = TestUtils.toMemory(json);
+ StringSink captured = new StringSink();
+ JsonParser parser = (code, tag, position) -> {
+ if (code == JsonLexer.EVT_VALUE) {
+ captured.clear();
+ captured.put(tag);
+ }
+ };
+ try (JsonLexer lexer = new JsonLexer(4, 1024)) {
+ // split inside the four hex digits: backslash-u-0-0 lands in the first chunk, 4-1 in the second
+ int split = json.indexOf(bs) + 4;
+ lexer.parse(address, address + split, parser);
+ lexer.parse(address + split, address + len, parser);
+ lexer.parseLast();
+ TestUtils.assertEquals("xAy", captured);
+ } finally {
+ Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testUnicodeEscapeWithNonAsciiCharInWindow() throws Exception {
+ assertMemoryLeak(() -> {
+ // a backslash-u escape whose four-hex window's first char is a non-ASCII code point, fed as the
+ // valid UTF-8 a hostile IdP response would carry (0xC3 0xA9 -> U+00E9, 233). parseHex4 indexes
+ // Numbers.hexNumbers (int[128]) only behind a c<128 guard, so 233 is a non-hex digit and the escape
+ // is kept verbatim (lenient), not decoded. WITHOUT the guard, hexNumbers[233] throws an
+ // ArrayIndexOutOfBoundsException that escapes as an unchecked exception - the OIDC callers catch
+ // only JsonException - so this pins that the guard is present.
+ byte[] bytes = {'{', '"', 'v', '"', ':', '"', 'x', '\\', 'u',
+ (byte) 0xC3, (byte) 0xA9, // valid UTF-8 for U+00E9 (e-acute); lands right after the backslash-u
+ 'A', 'B', 'C', 'y', '"', '}'};
+ int len = bytes.length;
+ long address = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT);
+ StringSink captured = new StringSink();
+ JsonParser parser = (code, tag, position) -> {
+ if (code == JsonLexer.EVT_VALUE) {
+ captured.clear();
+ captured.put(tag);
+ }
+ };
+ try {
+ for (int i = 0; i < len; i++) {
+ Unsafe.getUnsafe().putByte(address + i, bytes[i]);
+ }
+ try (JsonLexer lexer = new JsonLexer(4, 1024)) {
+ lexer.parse(address, address + len, parser);
+ lexer.parseLast();
+ // the escape stayed literal (lenient): x, then a verbatim backslash-u, then the decoded
+ // e-acute (U+00E9), then ABCy
+ TestUtils.assertEquals("x\\u\u00e9ABCy", captured);
+ }
+ } finally {
+ Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testStringEscapesExoticAndLenient() throws Exception {
+ assertMemoryLeak(() -> {
+ String bs = String.valueOf((char) 92); // a single backslash, built without a literal escape
+ // a surrogate pair (two backslash-u escapes) reassembles into the supplementary point U+1F600
+ assertDecodedValue("{\"v\":\"x" + bs + "uD83D" + bs + "uDE00y\"}",
+ "x" + new String(Character.toChars(0x1F600)) + "y");
+ // the backspace and form-feed arms
+ assertDecodedValue("{\"v\":\"a" + bs + "bb" + bs + "fc\"}",
+ "a" + ((char) 8) + "b" + ((char) 12) + "c");
+ // the lexer is deliberately lenient (not RFC 8259-strict) about malformed or unknown escapes:
+ // it keeps the backslash and the following text verbatim rather than failing the parse, so a
+ // literal backslash in non-conformant input is not silently lost. These pin that behavior and
+ // cover the lenient arms that otherwise carry most of the file's coverage:
+ assertDecodedValue("{\"v\":\"a" + bs + "xb\"}", "a" + bs + "xb"); // unknown escape -> kept verbatim
+ assertDecodedValue("{\"v\":\"a" + bs + "uZZZZb\"}", "a" + bs + "uZZZZb"); // non-hex unicode escape -> literal
+ assertDecodedValue("{\"v\":\"ab" + bs + "u12\"}", "ab" + bs + "u12"); // too few hex digits -> literal
+ // a lone (unpaired) high surrogate is emitted as-is, not dropped or replaced
+ assertDecodedValue("{\"v\":\"x" + bs + "uD83Dy\"}", "x" + ((char) 0xD83D) + "y");
+ });
+ }
+
+ @Test
+ public void testSettingsParserKeysDecodedThroughUnescape() throws Exception {
+ assertMemoryLeak(() -> {
+ // the line-protocol version probe parses /settings with JsonSettingsParser, whose keys now flow
+ // through the lexer's unescape pass. An escaped key (here a JSON unicode escape standing in for
+ // the letter 'o') must decode to the real key, otherwise the probe would miss the advertised
+ // versions and silently fall back to V1. The backslash is built from char 92, so this source
+ // carries no literal backslash-u sequence.
+ String esc = ((char) 92) + "u006f"; // a JSON unicode escape for 'o'
+ String json = "{\"line.proto.support.versi" + esc + "ns\":[1,2,3],\"cairo.max.file.name.length\":127}";
+ long address = TestUtils.toMemory(json);
+ int len = json.length();
+ try (AbstractLineHttpSender.JsonSettingsParser parser = new AbstractLineHttpSender.JsonSettingsParser();
+ JsonLexer lexer = new JsonLexer(1024, 1024)) {
+ lexer.parse(address, address + len, parser);
+ lexer.parseLast();
+ // the escaped "versions" key decoded and matched, so the highest advertised version was
+ // picked; a non-decoded key would leave the versions empty and fall back to V1
+ Assert.assertEquals(Sender.PROTOCOL_VERSION_V3, parser.getDefaultProtocolVersion());
+ Assert.assertEquals(127, parser.getMaxNameLen());
+ } finally {
+ Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ private static void assertDecodedValue(String json, String expected) throws JsonException {
+ int len = json.length();
+ long address = TestUtils.toMemory(json);
+ StringSink captured = new StringSink();
+ JsonParser parser = (code, tag, position) -> {
+ if (code == JsonLexer.EVT_VALUE) {
+ captured.clear();
+ captured.put(tag);
+ }
+ };
+ try (JsonLexer lexer = new JsonLexer(4, 1024)) {
+ lexer.parse(address, address + len, parser);
+ lexer.parseLast();
+ TestUtils.assertEquals(expected, captured);
+ } finally {
+ Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
private void assertError(String expected, int expectedPosition, String input) {
int len = input.length();
long address = TestUtils.toMemory(input);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java
new file mode 100644
index 000000000..a45a27c4f
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java
@@ -0,0 +1,549 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.line;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.test.cutlass.auth.MockOidcServer;
+import org.junit.Assert;
+import org.junit.Test;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Verifies that the error body a QuestDB HTTP endpoint returns on a failed flush is rendered safely
+ * into the {@link LineSenderException} message. A JSON error body has its string escapes resolved by the
+ * lexer, so a {@code message} or {@code errorId} field arrives fully decoded; an auth (401/403) body, a
+ * non-JSON body, and a body that fails to parse as JSON are echoed verbatim. In every case a hostile or
+ * proxied endpoint could otherwise smuggle real control characters, ANSI escapes or bidi overrides that
+ * forge a log line or rewrite a terminal when the exception text is printed. The sender must escape them,
+ * just as it does for column names in an error message.
+ *
+ * The dangerous bytes are built at runtime via {@code (char) 0x1b} (ESC) and {@code (char) 0x202e} (a
+ * right-to-left override), so this source file stays pure ASCII and carries none of the chars it guards.
+ */
+public class LineHttpSenderErrorResponseTest {
+
+ // ESC: the lead byte of an ANSI escape sequence (terminal hijack)
+ private static final char ESC = 0x1b;
+ // U+202E RIGHT-TO-LEFT OVERRIDE: reorders displayed text (visual spoofing)
+ private static final char RLO = 0x202e;
+
+ @Test(timeout = 30_000)
+ public void testMalformedResponseHeadOnFlushFailsOnceWithoutResending() throws Exception {
+ assertMemoryLeak(() -> {
+ // HttpHeaderParser rejects a response head it cannot parse - here a header block past its fixed
+ // 4096-byte buffer, the shape an intermediary stacking Set-Cookie/CSP produces - by throwing
+ // HttpException, a SIBLING of HttpClientException rather than a subclass. Uncaught it escaped
+ // flush0 entirely, taking with it the client.disconnect() that keeps the next flush off a
+ // connection holding a half-read response, and left flush() throwing a raw HttpException
+ // instead of the LineSenderException its contract promises.
+ //
+ // Caught, but NOT retried. The parser only ever runs on bytes that arrived, so the server
+ // answered: the batch is delivered, and the head is chosen by an intermediary, so the next
+ // attempt parses the same block and fails the same way. Routing it to the transport arm made a
+ // committed batch re-send until the retry budget ran out.
+ AtomicInteger requests = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> {
+ requests.incrementAndGet();
+ StringBuilder padding = new StringBuilder();
+ for (int i = 0; i < 5000; i++) {
+ padding.append('A');
+ }
+ return MockOidcServer.raw("HTTP/1.1 204 No Content\r\n"
+ + "X-Pad: " + padding + "\r\n\r\n");
+ })) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1) // only the flush hits the mock
+ .httpTimeoutMillis(1_000)
+ .retryTimeoutMillis(5_000) // a budget a re-send would visibly spend
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ long startNanos = System.nanoTime();
+ try {
+ sender.flush();
+ Assert.fail("an unparseable response head must fail the flush");
+ } catch (LineSenderException e) {
+ // the documented type, and a message that names what went wrong rather than
+ // reporting a transport failure that did not happen
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("Malformed HTTP response head"));
+ Assert.assertFalse("a head an intermediary will re-send identically is not retryable",
+ e.isRetryable());
+ }
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ Assert.assertEquals("a batch the server already answered must be sent exactly once",
+ 1, requests.get());
+ Assert.assertTrue("returned too slowly to have failed without retrying: "
+ + elapsedMillis + "ms", elapsedMillis < 5_000);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testProtocolDetectionErrorBodyControlAndBidiAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // when the caller does not pin a protocol version, build() probes the server for one; a
+ // non-success, non-404 probe response body is captured into the "Failed to detect server line
+ // protocol version" exception. A hostile or proxied endpoint must not splice control, ANSI or
+ // bidi chars into that message any more than into a flush error
+ String errorBody = "probe denied " + ESC + "[2J forged\n" + RLO + "moc.live";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) {
+ try {
+ // no protocolVersion(...) -> build() runs the detection probe; retryTimeoutMillis(0) makes
+ // it give up after the first failed probe instead of retrying to a deadline
+ Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .retryTimeoutMillis(0)
+ .build()
+ .close();
+ Assert.fail("expected protocol detection to fail and surface the server body");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("Failed to detect server line protocol version"));
+ Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("probe denied"));
+ Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b"));
+ Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e"));
+ Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0);
+ Assert.assertFalse("a raw newline must not leak: " + msg, msg.indexOf('\n') >= 0);
+ Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDribbledBodyUnderA2xxDoesNotResendTheBatch() throws Exception {
+ assertMemoryLeak(() -> {
+ // A flush whose response BODY dribbles (chunked headers sent, then the chunk-size line one byte at
+ // a time, never completing) aborts the read on the configured request timeout: the no-arg recv()
+ // the flush uses bounds the WHOLE body read, not each socket read. Drives that bound end to end
+ // over a real socket from a real flush (the Response classes are unit-tested in isolation; the ILP
+ // flush path - consumeChunkedResponse -> recv() - is covered here). Without the whole-read bound
+ // the dribble would re-arm the per-read timeout forever and this test would hit its @Test timeout.
+ //
+ // The status here is 200, so the server ALREADY COMMITTED these rows. The abort must therefore not
+ // reach flush0's catch, which treats HttpClientException as a transport error and re-sends the
+ // whole batch - duplicate rows on data the server accepted, with a retry budget that keeps trying.
+ // The bound is what made this reachable at all: base re-armed per socket read, so a
+ // dribbling-but-progressing body never aborted here.
+ AtomicInteger requests = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> {
+ requests.incrementAndGet();
+ return MockOidcServer.dribble();
+ })) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1) // skip the build-time probe: only the flush hits the dribble
+ .httpTimeoutMillis(1_000) // the whole-body-read bound the no-arg recv() applies
+ .retryTimeoutMillis(3_000) // a budget a re-send would visibly spend
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ long startNanos = System.nanoTime();
+ sender.flush();
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ // aborted on the ~1s whole-read bound. The mock dribbles for ~10s, so a per-read re-arm
+ // would not abort until ~11s (then the 30s @Test timeout); the < 5s ceiling fails on that
+ // path while giving the 1s bound generous CI headroom.
+ Assert.assertTrue("returned too fast to be the 1s read bound: " + elapsedMillis + "ms", elapsedMillis >= 500);
+ Assert.assertTrue("returned too slowly - re-armed per-read, or retried? " + elapsedMillis + "ms", elapsedMillis < 5_000);
+ Assert.assertEquals("a committed batch must be sent exactly once; a drain failure after a "
+ + "2xx must not re-send it", 1, requests.get());
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDrainFailureAfterASuccessfulFlushReportsItsReason() throws Exception {
+ assertMemoryLeak(() -> {
+ // A 2xx IS the commit, so a body-drain abort after it changes no outcome - but it does drop the
+ // connection, because unconsumed bytes would mis-frame the next response. Against a server that
+ // dribbles every response that is one reconnect per flush, and the catch used to bind the
+ // exception and discard it, leaving the churn with nothing to explain it.
+ ch.qos.logback.classic.Logger senderLog =
+ (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(
+ "io.questdb.client.cutlass.line.http.AbstractLineHttpSender");
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ Level saved = senderLog.getLevel();
+ senderLog.setLevel(Level.ALL);
+ senderLog.addAppender(appender);
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.dribble(200))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .httpTimeoutMillis(1_000)
+ .retryTimeoutMillis(3_000)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush(); // the 2xx committed; only the drain aborts
+ }
+ String drainLine = null;
+ for (ILoggingEvent event : appender.list) {
+ if (event.getFormattedMessage().contains("could not drain the response body")) {
+ drainLine = event.getFormattedMessage();
+ break;
+ }
+ }
+ Assert.assertNotNull("a drain abort that drops the connection must say so; logged: "
+ + appender.list, drainLine);
+ Assert.assertTrue("and it must carry WHY, not just that it happened: " + drainLine,
+ drainLine.contains("reason=") && !drainLine.contains("reason=null"));
+ } finally {
+ senderLog.detachAppender(appender);
+ senderLog.setLevel(saved);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testDribbledBodyUnderAnErrorStatusStillSurfacesTheStatus() throws Exception {
+ assertMemoryLeak(() -> {
+ // The mirror of the 2xx case on the error path. The STATUS is the verdict; the body is only detail
+ // for the message. Reading that body can now abort on the whole-read bound, and if the abort
+ // escapes it reaches flush0's catch, which reclassifies a definitive 401 as a transport failure:
+ // the sender then burns the whole retry budget re-sending against an endpoint that will keep
+ // refusing, and finally reports "Connection Failed", with the real status nowhere in the message.
+ AtomicInteger requests = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> {
+ requests.incrementAndGet();
+ return MockOidcServer.dribble(401);
+ })) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .httpTimeoutMillis(1_000)
+ .retryTimeoutMillis(3_000)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ long startNanos = System.nanoTime();
+ try {
+ sender.flush();
+ Assert.fail("expected the 401 to surface");
+ } catch (LineSenderException e) {
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ String msg = e.getMessage();
+ Assert.assertTrue("the real status must reach the caller: " + msg,
+ msg.contains("http-status=401"));
+ Assert.assertFalse("a definitive 401 must not be reported as a transport failure: " + msg,
+ msg.contains("Connection Failed"));
+ // and WHY the body could not be read, which is the half the status cannot supply:
+ // a read that timed out, a peer that vanished and a mangled chunk all arrive here
+ // as the same status, and only this tells an operator which one to act on
+ Assert.assertTrue("the reason the body read failed must reach the caller: " + msg,
+ msg.contains("reason=") && !msg.contains("reason="));
+ Assert.assertTrue("and it must be the read abort, not something invented: " + msg,
+ msg.contains("timed out"));
+ Assert.assertTrue("a definitive status must not spend the retry budget: "
+ + elapsedMillis + "ms", elapsedMillis < 3_000);
+ Assert.assertEquals("a definitive status must not be retried", 1, requests.get());
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerAuthErrorBodyControlAndBidiAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // a 401/403 body is echoed into the exception verbatim (read as raw bytes, not through the JSON
+ // parser), so a hostile or proxied endpoint could splice raw control, ANSI or bidi chars straight
+ // into the LineSenderException; the sender must escape them just like the JSON-field path
+ String errorBody = "denied " + ESC + "[2J forged\n" + RLO + "moc.live";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(401, errorBody))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the server's auth error to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("authentication error"));
+ Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("denied"));
+ Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b"));
+ Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e"));
+ Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0);
+ Assert.assertFalse("a raw newline must not leak: " + msg, msg.indexOf('\n') >= 0);
+ Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0);
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerErrorStatusLineControlCharsAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // the HTTP status-line token is echoed into the exception as "[http-status=...]". The header parser
+ // copies it verbatim between the two spaces, so a hostile or proxied endpoint can smuggle control or
+ // ANSI bytes there; a non-3-char token bypasses the numeric status checks and reaches the generic
+ // error path, so the status render must escape them too, not just the body. A bidi override is a
+ // multi-byte char the raw-response writer's US-ASCII encoding would drop, so this case uses an ESC;
+ // the bidi cases above cover the body
+ String body = "upstream error";
+ // a malformed status code "400[m" (6 chars, not 3) carries an ESC between the two spaces;
+ // text/plain keeps it off the JSON parser, so it reaches the generic path that renders the status
+ String rawResponse = "HTTP/1.1 400" + ESC + "[m FORGED\r\n"
+ + "Content-Type: text/plain\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n"
+ + Integer.toHexString(body.length()) + "\r\n" + body + "\r\n"
+ + "0\r\n\r\n";
+ try (MockOidcServer server = new MockOidcServer((method, path, b) -> MockOidcServer.raw(rawResponse))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the server's error to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("Could not flush buffer"));
+ // the ESC smuggled into the status token arrives escaped, never as a raw byte that
+ // could drive an ANSI terminal sequence
+ Assert.assertTrue("the status-line ESC must be escaped: " + msg, msg.contains("\\u001b"));
+ Assert.assertFalse("a raw ESC must not leak from the status line: " + msg, msg.indexOf(0x1b) >= 0);
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerJsonErrorBidiAndZeroWidthAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // beyond C0 controls, a hostile or proxied endpoint can smuggle bidi overrides and zero-width
+ // characters (as JSON \\uXXXX escapes the lexer decodes) that reorder or hide text in a terminal.
+ // The sender must escape these too, matching the OIDC display sanitizer, so the rendered message
+ // cannot be visually spoofed
+ String errorBody = "{"
+ + "\"code\":\"invalid\","
+ + "\"message\":\"safe\\u202ehidden\\u200bend\","
+ + "\"line\":1,"
+ + "\"errorId\":\"E1\""
+ + "}";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the server's JSON error to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ // the visible text survives, but the bidi override (U+202E) and the zero-width space
+ // (U+200B) arrive escaped, never as raw code points that could reorder or hide text
+ Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("safe"));
+ Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("hidden"));
+ Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e"));
+ Assert.assertTrue("the zero-width space must be escaped: " + msg, msg.contains("\\u200b"));
+ Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0);
+ Assert.assertFalse("a raw zero-width space must not leak: " + msg, msg.indexOf(0x200b) >= 0);
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testQuestDbRowErrorRendersTheDecodedNewlineAsAnEscape() throws Exception {
+ assertMemoryLeak(() -> {
+ // Pins the rendering of the single most common ILP failure. QuestDB's own
+ // LineHttpProcessorState builds its error as error.put("\nerror in line ")... - a REAL newline -
+ // and escapeJsonStr sends it as the JSON escape \n. Before the lexer decoded escapes the client
+ // copied those two characters through verbatim; now it decodes them to a newline and
+ // putAsPrintable re-escapes it, so the text a user (and their log scraper) sees changed from
+ // a two-character JSON escape to a six-character unicode escape. Neither form leaks a raw
+ // newline, which is the point of putAsPrintable, but the
+ // rendering is user-visible and nothing pinned it: the sibling tests here assert only that
+ // fragments either side of the newline survive, which holds under both.
+ String errorBody = "{"
+ + "\"code\":\"invalid\","
+ + "\"message\":\"invalid field format\\nerror in line 1: table: t, column: v\","
+ + "\"line\":1,"
+ + "\"errorId\":\"ABC-1\""
+ + "}";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the server's row error to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue("the newline must render as its unicode escape: " + msg,
+ msg.contains("invalid field format\\u000aerror in line 1: table: t, column: v"));
+ Assert.assertFalse("the raw JSON escape must not survive undecoded: " + msg,
+ msg.contains("format\\nerror"));
+ Assert.assertFalse("and no raw newline may reach the message: " + msg,
+ msg.indexOf('\n') >= 0);
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerJsonErrorControlCharsAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // the server's error body carries control characters as JSON escapes: an ESC and a newline in
+ // the message, and an ESC in the errorId. The lexer decodes them to real bytes, so the sender's
+ // error rendering is what must neutralize them
+ String errorBody = "{"
+ + "\"code\":\"invalid\","
+ + "\"message\":\"bad\\u001b[m\\nthing\","
+ + "\"line\":42,"
+ + "\"errorId\":\"E\\u001bID\""
+ + "}";
+ // a chunked 400 with Content-Type application/json drives the flush failure through the sender's
+ // JSON error parser (a 4xx response is asserted to be chunked before parsing)
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ // an explicit protocol version keeps build() from probing the server, so the only
+ // request is the flush below
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the server's JSON error to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("Could not flush buffer"));
+ // the decoded message text survives...
+ Assert.assertTrue("decoded message text must be preserved: " + msg, msg.contains("bad"));
+ Assert.assertTrue("decoded message text must be preserved: " + msg, msg.contains("thing"));
+ Assert.assertTrue("errorId must be present with its ESC escaped: " + msg, msg.contains("id: E\\u001bID"));
+ // ...but no raw control byte reaches the message: no ESC (ANSI injection) and no
+ // newline (log-line forging); both arrive escaped instead
+ Assert.assertTrue("the decoded ESC must be escaped, not raw: " + msg, msg.contains("\\u001b"));
+ Assert.assertFalse("a raw ESC must not leak into the message: " + msg, msg.indexOf(0x1b) >= 0);
+ Assert.assertFalse("a raw newline must not leak into the message: " + msg, msg.indexOf('\n') >= 0);
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerMalformedJsonErrorBodyControlAndBidiAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // a body sent as application/json but not parseable as a QuestDB error object (a proxy/WAF page,
+ // or an unexpected first key) makes the JSON parser throw; the fallback renders the raw body, which
+ // must still be escaped. The unexpected first key "forged" forces the parse failure; the ESC and
+ // bidi override ride in the value and must surface escaped, not raw
+ String errorBody = "{\"forged\":\"x " + ESC + "[2J y " + RLO + " z\"}";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the malformed server response to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("Could not flush buffer"));
+ // the raw body is shown (so the user can diagnose the unexpected response)...
+ Assert.assertTrue("the raw body must be preserved: " + msg, msg.contains("forged"));
+ // ...but the smuggled control and bidi chars arrive escaped, never raw
+ Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b"));
+ Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e"));
+ Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0);
+ Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0);
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testServerNonJsonErrorBodyControlCharsAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ // a proxy or WAF can return a non-JSON error body (here text/plain) with raw ANSI/control bytes;
+ // it reaches the generic error path, which must escape them before they hit a log or terminal.
+ // The body is all ASCII (a real ESC and a newline) so it survives the raw response writer's
+ // US-ASCII encoding; bidi is covered by the auth/malformed cases above
+ String body = "upstream down " + ESC + "[31m forged\nsecond line";
+ // hand-craft a chunked text/plain response: the generic path only reads the body when chunked, and
+ // a non-application/json content type keeps it off the JSON parser
+ String rawResponse = "HTTP/1.1 400 Bad Request\r\n"
+ + "Content-Type: text/plain\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n"
+ + Integer.toHexString(body.length()) + "\r\n" + body + "\r\n"
+ + "0\r\n\r\n";
+ try (MockOidcServer server = new MockOidcServer((method, path, b) -> MockOidcServer.raw(rawResponse))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the server's non-JSON error to surface as a LineSenderException");
+ } catch (LineSenderException e) {
+ String msg = e.getMessage();
+ Assert.assertTrue(msg, msg.contains("Could not flush buffer"));
+ Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("upstream down"));
+ Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b"));
+ Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0);
+ Assert.assertFalse("a raw newline must not leak: " + msg, msg.indexOf('\n') >= 0);
+ }
+ }
+ }
+ });
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderInterfaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderInterfaceTest.java
index 0d8ae3d58..2ce5ada09 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderInterfaceTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderInterfaceTest.java
@@ -25,10 +25,14 @@
package io.questdb.client.test.cutlass.line;
import io.questdb.client.Sender;
+import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.std.bytes.DirectByteSlice;
import org.junit.Assert;
import org.junit.Test;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
/**
* Exercises {@link Sender#reset()} and {@link Sender#bufferView()} on the
* HTTP transport. The HTTP sender connects lazily on the first
@@ -51,6 +55,53 @@ public void testBufferViewReflectsAccumulatedRows() {
}
}
+ @Test
+ public void testRejectedExplicitTimestampWritesNothing() {
+ // at(timestamp) validates BEFORE it writes, and that ordering is the whole reason it does not simply
+ // delegate to atNow(): a row rejected after the timestamp went into the buffer would leave those bytes
+ // for the NEXT row to inherit, splicing a stray " 1700000000000000000" into an otherwise valid line.
+ // Both rejected states are covered - no table name, and a table with no symbols or columns - over V1
+ // and V2 (V3 inherits V2's at()), for both overloads.
+ for (int version = 1; version <= 2; version++) {
+ for (int overload = 0; overload < 2; overload++) {
+ String config = "http::addr=127.0.0.1:1;auto_flush=off;protocol_version=" + version + ';';
+ String where = "[version=" + version + " overload=" + overload + ']';
+
+ try (Sender sender = Sender.fromConfig(config)) {
+ try {
+ at(sender, overload);
+ Assert.fail("at() with no table name must be rejected " + where);
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("no table name was provided"));
+ }
+ Assert.assertEquals("a rejected at() must not write a timestamp " + where,
+ 0, sender.bufferView().size());
+ }
+
+ try (Sender sender = Sender.fromConfig(config)) {
+ sender.table("t");
+ int afterTableName = sender.bufferView().size();
+ Assert.assertTrue("preconditions: table() writes " + where, afterTableName > 0);
+ try {
+ at(sender, overload);
+ Assert.fail("at() with no symbols or columns must be rejected " + where);
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("no symbols or columns were provided"));
+ }
+ Assert.assertEquals("a rejected at() must not write a timestamp " + where,
+ afterTableName, sender.bufferView().size());
+
+ // and the half-built row is still intact: finishing it properly must work
+ sender.longColumn("v", 1L);
+ sender.atNow();
+ Assert.assertTrue("the row must still complete after the rejection " + where,
+ sender.bufferView().size() > afterTableName);
+ }
+ }
+ }
+ }
+
@Test
public void testResetClearsBufferAndAllowsNewRows() {
try (Sender sender = Sender.fromConfig("http::addr=127.0.0.1:1;auto_flush=off;protocol_version=1;")) {
@@ -72,4 +123,12 @@ public void testResetClearsBufferAndAllowsNewRows() {
sender.bufferView().size() > 0);
}
}
+
+ private static void at(Sender sender, int overload) {
+ if (overload == 0) {
+ sender.at(1_700_000_000_000_000_000L, ChronoUnit.NANOS);
+ } else {
+ sender.at(Instant.ofEpochMilli(1_700_000_000_000L));
+ }
+ }
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java
new file mode 100644
index 000000000..3b416bf77
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java
@@ -0,0 +1,473 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.line;
+
+import io.questdb.client.HttpTokenProvider;
+import io.questdb.client.Sender;
+import io.questdb.client.std.bytes.DirectByteSlice;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.line.http.AbstractLineHttpSender;
+import io.questdb.client.std.str.Utf8String;
+import io.questdb.client.test.cutlass.auth.MockOidcServer;
+import io.questdb.client.test.tools.HandOffCharSequence;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Verifies that a {@link Sender} built with {@link Sender.LineSenderBuilder#httpTokenProvider}
+ * does not query the provider on the build path: the first token pull is deferred to the first
+ * row. That lets a provider which signs in lazily - the documented
+ * {@code .httpTokenProvider(auth::getToken)} - be wired before the interactive sign-in
+ * has completed.
+ *
+ * The deferral tests pin an explicit {@code protocol_version} to keep {@link Sender.LineSenderBuilder#build()}
+ * from probing the server, and disable auto-flush, so rows buffer against a port nobody listens on without
+ * opening a connection. The end-to-end tests instead flush against a {@link MockOidcServer} and assert the
+ * pulled token actually reaches the {@code Authorization: Bearer} header on the wire, is re-queried per
+ * request as a rotating provider refreshes, and is re-sent verbatim (not re-pulled) on a retry. Each test
+ * runs under {@code assertMemoryLeak} so the sender's native buffers are proven freed on close.
+ */
+public class LineHttpSenderTokenProviderTest {
+
+ @Test
+ public void testBufferViewIsEmptyNotSentinelWhileTheTokenIsPending() {
+ // With a provider configured, newRequest() leaves the request at the header stage - withContent() is
+ // deferred until the first row stamps the Authorization header - so contentStart holds its -1
+ // sentinel between every flush and the next row. getContentLength() already reported 0 for that
+ // state, so bufferView() handed out a view that is empty by length but whose base address is a
+ // non-zero, unusable pointer: a ptr() != 0 test reads as true, and arithmetic on it is nonsense.
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:1")
+ .httpTokenProvider(() -> "TOKEN")
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .build()) {
+ DirectByteSlice pending = sender.bufferView();
+ Assert.assertEquals("an empty buffer must report a zero base address, not the -1 sentinel",
+ 0L, pending.ptr());
+ Assert.assertEquals(0, pending.size());
+
+ // and once a row stamps the token and opens the content section, the view is real again
+ sender.table("t").longColumn("v", 1L).atNow();
+ DirectByteSlice afterRow = sender.bufferView();
+ Assert.assertTrue("a stamped request must expose a usable base address", afterRow.ptr() > 0);
+ Assert.assertTrue("and a non-empty buffer", afterRow.size() > 0);
+ }
+ }
+
+ @Test(timeout = 30_000)
+ public void testAtWithoutTableDoesNotCorruptTheAuthorizationHeader() throws Exception {
+ assertMemoryLeak(() -> {
+ // Regression: at() used to write the leading space and the timestamp BEFORE atNow() validated the
+ // row state. With a provider, newRequest() leaves the request at the header stage (withContent()
+ // deferred until the first row stamps the Authorization header), so those bytes landed in the HTTP
+ // HEADER block, on a line of their own. The next row's "Authorization: Bearer ..." was then appended
+ // to that line, making it an obs-fold continuation of User-Agent (RFC 7230) instead of a header of
+ // its own - so the flush went out with NO credential and the server answered 401, after which
+ // close() dropped the buffered rows. cancelRow() could not undo it: trimContentToLen only rewinds
+ // within the content section, and it early-returns while the token is pending anyway.
+ // Both at() overloads are covered, over V1 and V2 (V3 inherits V2's).
+ int[] versions = {Sender.PROTOCOL_VERSION_V1, Sender.PROTOCOL_VERSION_V2};
+ for (int i = 0; i < versions.length; i++) {
+ for (int overload = 0; overload < 2; overload++) {
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(versions[i])
+ .disableAutoFlush()
+ .httpTokenProvider(() -> "TOKEN")
+ .build()) {
+ try {
+ if (overload == 0) {
+ sender.at(1_700_000_000_000_000_000L, ChronoUnit.NANOS);
+ } else {
+ sender.at(Instant.ofEpochMilli(1_700_000_000_000L));
+ }
+ Assert.fail("expected at() with no table name to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("no table name was provided"));
+ }
+ // the documented recovery, and the sender must still be usable afterwards
+ sender.cancelRow();
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush();
+ }
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals("exactly one flush must reach the server", 1, auth.size());
+ // null here means the rejected at() spliced bytes ahead of the header, so the mock's
+ // parser never saw a line whose field name is "Authorization"
+ Assert.assertEquals("the token must reach the wire as its own header",
+ "Bearer TOKEN", auth.get(0));
+ }
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception {
+ assertMemoryLeak(() -> {
+ // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getToken
+ AtomicBoolean signedIn = new AtomicBoolean(false);
+ HttpTokenProvider provider = () -> {
+ if (!signedIn.get()) {
+ throw new LineSenderException("no token has been obtained yet");
+ }
+ return "TOKEN";
+ };
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:1")
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ // build() must succeed even though the provider cannot supply a token yet, so the natural
+ // "construct the sender, sign in, then send" ordering is possible
+ try {
+ sender.table("t").longColumn("v", 1L).atNow();
+ Assert.fail("expected the not-yet-signed-in provider to fail the first row");
+ } catch (LineSenderException e) {
+ // the deferred pull surfaces the provider's error at first use, not at build time
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("no token has been obtained yet"));
+ }
+ // after signing in, the still-pending stamp is retried and the row is accepted
+ signedIn.set(true);
+ sender.table("t").longColumn("v", 1L).atNow();
+ Assert.assertTrue("row must be buffered after signing in", sender.bufferView().size() > 0);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testCancelRowWithPendingTokenDoesNotCorruptRequest() throws Exception {
+ assertMemoryLeak(() -> {
+ // Regression: with an httpTokenProvider, newRequest() defers the token and leaves the request at the
+ // header stage (withContent() not yet run), so the native contentStart is still the -1 sentinel and
+ // no row bytes are buffered. cancelRow() must be a safe no-op in that window: trimContentToLen(0)
+ // would otherwise set the write pointer to contentStart + 0 == -1, and the next buffer write (the
+ // deferred Authorization header on the following row) would segfault the JVM. The window is entered
+ // after build() and again after every flush (reset() re-arms the pending token); a rejected table
+ // name - validateTableName() runs BEFORE the token is stamped - is a mainstream way to reach a
+ // cancelRow() with the token still pending.
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ AtomicInteger calls = new AtomicInteger();
+ HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ // (1) cancelRow immediately after build(), token pending, nothing buffered: before the fix the
+ // write pointer went to -1 and the following row's write segfaulted the JVM
+ sender.cancelRow();
+ Assert.assertEquals("cancelRow must not pull the deferred token", 0, calls.get());
+
+ // the sender is still usable: a real row buffers, flushes, and carries the token to the wire
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush();
+
+ // (2) after a flush the token is pending again; cancelRow in that window must also be a safe
+ // no-op, and the next row must still send its (rotated) token
+ sender.cancelRow();
+ sender.table("t").longColumn("v", 2L).atNow();
+ sender.flush();
+ }
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals("both flushes must reach the server", 2, auth.size());
+ Assert.assertEquals("Bearer TOKEN-1", auth.get(0));
+ Assert.assertEquals("Bearer TOKEN-2", auth.get(1));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testChangedProviderTokenIsRevalidated() throws Exception {
+ assertMemoryLeak(() -> {
+ // every pulled token is validated per flush, so a token that CHANGES to a bad one must be rejected.
+ // First flush a valid token, then return a distinct CR/LF token and require the next flush to reject
+ // it rather than splice it onto the wire. (The same-instance-mutated case - a reused buffer whose
+ // content changes - is covered by testMutatedSameInstanceProviderTokenIsRevalidated.)
+ AtomicInteger calls = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ HttpTokenProvider provider = () -> calls.incrementAndGet() == 1
+ ? "GOODTOKEN"
+ : "abc" + (char) 0x0d + (char) 0x0a + "def"; // second pull: CR/LF injected
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush(); // first flush: GOODTOKEN validated and sent
+ try {
+ // the second flush's first row re-pulls the provider -> the changed, bad token
+ sender.table("t").longColumn("v", 2L).atNow();
+ sender.flush();
+ Assert.fail("a changed, bad token must be re-validated and rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("control or non-ASCII character"));
+ }
+ }
+ Assert.assertEquals("the provider is re-pulled per flush", 2, calls.get());
+ }
+ });
+ }
+
+ @Test
+ public void testControlOrNonAsciiProviderTokenIsRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // a token carrying a control or non-ASCII char is forbidden by the HttpTokenProvider contract: a
+ // CR/LF would inject into the request line and a non-ASCII byte is silently truncated by the ASCII
+ // header writer, so the sender must reject it at first use rather than splice a corrupt or injected
+ // "Authorization: Bearer " header onto the wire. Strings are built with explicit char values to keep
+ // this source pure ASCII.
+ assertProviderTokenRejected(() -> "abc" + (char) 0x0d + (char) 0x0a + "def", "control or non-ASCII character"); // CR/LF
+ assertProviderTokenRejected(() -> "tok" + (char) 0x00 + "en", "control or non-ASCII character"); // NUL
+ assertProviderTokenRejected(() -> (char) 0x1b + "[31mred", "control or non-ASCII character"); // ANSI escape
+ assertProviderTokenRejected(() -> "tok" + (char) 0xe9 + "n", "control or non-ASCII character"); // non-ASCII
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testFailedFlushReSendsSameTokenWithoutRePull() throws Exception {
+ assertMemoryLeak(() -> {
+ // a failed flush preserves the buffered request - token included - and re-sends it verbatim on retry
+ // rather than re-pulling the provider (the documented contract on httpTokenProvider()). Here the first
+ // send gets a retryable 500 and the retry must carry the SAME baked token, with the provider queried
+ // only once - so a rotating provider does not change the credential mid-retry of one buffered batch.
+ AtomicInteger requests = new AtomicInteger();
+ try (MockOidcServer server = new MockOidcServer((method, path, body) ->
+ requests.incrementAndGet() == 1
+ ? MockOidcServer.chunkedJson(500, "boom") // first send: retryable server error
+ : MockOidcServer.json(204, ""))) { // retry: success
+ AtomicInteger calls = new AtomicInteger();
+ HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush(); // first send 500 -> retry -> 204
+ }
+ Assert.assertEquals("the provider must be pulled once, not re-pulled on retry", 1, calls.get());
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals("the failed send plus its retry must be two requests", 2, auth.size());
+ Assert.assertEquals("the first send carries the pulled token", "Bearer TOKEN-1", auth.get(0));
+ Assert.assertEquals("the retry must re-send the same baked token", "Bearer TOKEN-1", auth.get(1));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testMutatedSameInstanceProviderTokenIsRevalidated() throws Exception {
+ assertMemoryLeak(() -> {
+ // A provider may reuse one CharSequence buffer (the idiomatic zero-alloc style) and return the SAME
+ // instance every call. HttpTokenProvider.getToken() makes no immutability promise, so the sender
+ // must re-validate EVERY pulled token, not trust instance identity: a token mutated in place to
+ // carry a CR/LF between flushes must be rejected, not spliced verbatim into the "Authorization:
+ // Bearer" header (authToken writes it with no CR/LF filtering). This pins the fix that dropped the
+ // identity-cache skip; before it, the second flush injected a header past the auth line.
+ StringBuilder token = new StringBuilder("GOODTOKEN"); // one instance, mutated in place below
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(() -> token) // always the SAME instance
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush(); // first flush: GOODTOKEN validated and sent
+ // mutate the SAME instance to inject a CR/LF header break
+ token.setLength(0);
+ token.append("abc").append((char) 0x0d).append((char) 0x0a).append("X-Injected: pwned");
+ try {
+ sender.table("t").longColumn("v", 2L).atNow();
+ sender.flush();
+ Assert.fail("a mutated same-instance token carrying CR/LF must be re-validated and rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("control or non-ASCII character"));
+ }
+ }
+ // only the first (valid) flush reached the wire; the injected token was rejected before any send
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals("only the valid first flush must reach the server", 1, auth.size());
+ Assert.assertEquals("Bearer GOODTOKEN", auth.get(0));
+ }
+ });
+ }
+
+ @Test
+ public void testTokenMutatedBetweenValidationAndTheHeaderCannotSplice() throws Exception {
+ assertMemoryLeak(() -> {
+ // The sibling test above covers a buffer mutated BETWEEN flushes, which re-validation catches.
+ // This is the window inside ONE flush: validateToken scanned the provider's sequence and
+ // authToken then re-read it, so a mutation landing between those two reads passed the check and
+ // was spliced verbatim into the Authorization header. HttpTokenProvider.getToken() explicitly
+ // invites a reused mutable buffer, and the SPI is exported, so the reader has to be the one that
+ // makes this safe: the pulled value is snapshotted before it is validated, and the bytes checked
+ // are the bytes sent.
+ //
+ // HandOffToken swaps its content the instant a full scan completes - i.e. exactly when
+ // validateToken finishes - so every later read sees the CR/LF splice.
+ final String clean = "GOODTOKEN";
+ final String spliced = "abc" + (char) 0x0d + (char) 0x0a + "X-Injected: pwned";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(() -> new HandOffCharSequence(clean, spliced))
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush();
+ }
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals(1, auth.size());
+ Assert.assertEquals("the header must carry the bytes that were validated, not a value "
+ + "swapped in after the scan", "Bearer " + clean, auth.get(0));
+ }
+ });
+ }
+
+ @Test
+ public void testNullOrEmptyProviderTokenIsRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // the HttpTokenProvider contract forbids a null or empty token; the sender must reject it with a
+ // clear LineSenderException at first use, rather than silently send a malformed "Authorization:
+ // Bearer " header that the server only answers with a 401 far from the cause
+ assertProviderTokenRejected(() -> null, "null or empty token");
+ assertProviderTokenRejected(() -> "", "null or empty token");
+ assertProviderTokenRejected(() -> " ", "null or empty token");
+ });
+ }
+
+ @Test
+ public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger calls = new AtomicInteger();
+ HttpTokenProvider provider = () -> {
+ calls.incrementAndGet();
+ return "TOKEN";
+ };
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:1")
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ // build() must not query the provider: a lazily-signing-in provider would not have a token yet
+ Assert.assertEquals("provider must not be queried at build time", 0, calls.get());
+ // the first row pulls the deferred token so the first send will carry it
+ sender.table("t").longColumn("v", 1L).atNow();
+ Assert.assertEquals("provider must be queried when the first row starts", 1, calls.get());
+ // a second row in the same un-flushed batch reuses the same request, so it does not re-pull
+ sender.table("t").longColumn("v", 2L).atNow();
+ Assert.assertEquals("provider must not be re-queried within the same batch", 1, calls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testPutRawMessageStampsPendingToken() throws Exception {
+ assertMemoryLeak(() -> {
+ // putRawMessage() sends a pre-formatted ILP line as the first row; it must stamp the deferred provider
+ // token first, or the raw message would ship with no Authorization header. F7: covers the
+ // stampTokenIfPending() call that putRawMessage() gained.
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ AtomicInteger calls = new AtomicInteger();
+ HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ ((AbstractLineHttpSender) sender).putRawMessage(new Utf8String("t v=1i\n"));
+ sender.flush();
+ }
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals("the raw-message flush must reach the server", 1, auth.size());
+ Assert.assertEquals("putRawMessage must carry the provider token", "Bearer TOKEN-1", auth.get(0));
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testTokenReachesAuthorizationHeaderAndRotatesPerFlush() throws Exception {
+ assertMemoryLeak(() -> {
+ // end-to-end against a real socket: the pulled token must reach the "Authorization: Bearer" header
+ // on the wire (not merely be pulled), and a rotating provider must be re-queried per request so a
+ // long-lived sender follows token refreshes rather than sending a token captured once.
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
+ AtomicInteger calls = new AtomicInteger();
+ HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ sender.flush();
+ sender.table("t").longColumn("v", 2L).atNow();
+ sender.flush();
+ }
+ List auth = server.requestAuthHeaders();
+ Assert.assertEquals("two flushes must send two requests", 2, auth.size());
+ Assert.assertEquals("the first request must carry the first pulled token", "Bearer TOKEN-1", auth.get(0));
+ Assert.assertEquals("the second flush must re-query the provider and carry the rotated token", "Bearer TOKEN-2", auth.get(1));
+ }
+ });
+ }
+
+ private static void assertProviderTokenRejected(HttpTokenProvider provider, String expectedMessage) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:1")
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .disableAutoFlush()
+ .httpTokenProvider(provider)
+ .build()) {
+ try {
+ sender.table("t").longColumn("v", 1L).atNow();
+ Assert.fail("expected an invalid provider token to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage));
+ }
+ }
+ }
+
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionRetryableTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionRetryableTest.java
new file mode 100644
index 000000000..99e9423c4
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionRetryableTest.java
@@ -0,0 +1,174 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.line;
+
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.test.cutlass.auth.MockOidcServer;
+import org.junit.Assert;
+import org.junit.Test;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Covers {@link LineSenderException#isRetryable()}.
+ *
+ * The class documentation tells a caller to act on exactly this distinction - retry {@code flush()} on a
+ * transient failure, close or {@code reset()} on a permanent one - and the sender already computes the
+ * answer for every failure it raises. The two-argument constructor accepted that classification and then
+ * dropped it on the floor, so the advice was unactionable: six call sites passed a flag no caller could
+ * read.
+ */
+public class LineSenderExceptionRetryableTest {
+
+ @Test
+ public void testConstructorsWithoutAClassificationReportNotRetryable() {
+ // false means "not classified as retryable", not "proven permanent". These constructors carry no
+ // classification at all, and false is the conservative direction for a caller that retries only
+ // on true - it stops rather than spins.
+ Assert.assertFalse(new LineSenderException("boom").isRetryable());
+ Assert.assertFalse(new LineSenderException(new RuntimeException("boom")).isRetryable());
+ Assert.assertFalse(new LineSenderException("boom", new RuntimeException("boom")).isRetryable());
+ }
+
+ @Test
+ public void testExplicitClassificationSurvivesConstruction() {
+ Assert.assertTrue(new LineSenderException("transient", true).isRetryable());
+ Assert.assertFalse(new LineSenderException("permanent", false).isRetryable());
+ // the flag must survive the fluent message building every call site does after construction
+ LineSenderException built = new LineSenderException("transient", true)
+ .put(" [http-status=").put(503).put(']');
+ Assert.assertTrue("building the message must not lose the classification", built.isRetryable());
+ }
+
+ @Test(timeout = 30_000)
+ public void testADefinitiveStatusFromTheSenderIsNotRetryable() throws Exception {
+ assertMemoryLeak(() -> {
+ // End to end, through the sender's own classification rather than a hand-built exception: a 401
+ // is definitive, so a caller must be able to tell it from a 503 and stop instead of re-flushing
+ // into an endpoint that will keep refusing.
+ // A CHUNKED 401: flush0 asserts response.isChunked() on the error branch, and MockOidcServer.json
+ // writes a Content-Length body, so the plain helper trips that assert (with -ea on) before the
+ // classification is ever reached.
+ final String errorBody = "{\"code\":\"unauthorized\"}";
+ final String chunked401 = "HTTP/1.1 401 Unauthorized\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n"
+ + Integer.toHexString(errorBody.length()) + "\r\n" + errorBody + "\r\n0\r\n\r\n";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) ->
+ MockOidcServer.raw(chunked401))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .httpTimeoutMillis(1_000)
+ .retryTimeoutMillis(1_000)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the 401 to surface");
+ } catch (LineSenderException e) {
+ Assert.assertFalse("a 401 is definitive; a caller told to retry on it would spin "
+ + "against an endpoint that keeps refusing: " + e.getMessage(),
+ e.isRetryable());
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testARetryableStatusFromTheSenderIsRetryable() throws Exception {
+ assertMemoryLeak(() -> {
+ // The other direction, and the one that carries the risk. assertFalse cannot tell a CLASSIFIED
+ // "permanent" from an UNCLASSIFIED exception, because the three constructors that carry no
+ // classification also report false - so the 401 test above passes just as happily against a
+ // sender that stopped classifying altogether. Only a true here proves the flag is computed and
+ // survives the throw.
+ //
+ // A 503 exhausts the retry budget and then throws with retryable=true. Chunked, because flush0
+ // asserts response.isChunked() on the error branch and the plain helper writes Content-Length.
+ final String errorBody = "{\"code\":\"unavailable\"}";
+ final String chunked503 = "HTTP/1.1 503 Service Unavailable\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n"
+ + Integer.toHexString(errorBody.length()) + "\r\n" + errorBody + "\r\n0\r\n\r\n";
+ try (MockOidcServer server = new MockOidcServer((method, path, body) ->
+ MockOidcServer.raw(chunked503))) {
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + server.port())
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .httpTimeoutMillis(1_000)
+ .retryTimeoutMillis(100)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the 503 to surface once the retry budget is spent");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("503"));
+ Assert.assertTrue("a 503 is transient; a caller told to close or reset() on it would "
+ + "tear down a healthy sender and drop the buffered batch: "
+ + e.getMessage(),
+ e.isRetryable());
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testATransportFailureFromTheSenderIsRetryable() throws Exception {
+ assertMemoryLeak(() -> {
+ // The sender's other retryable=true site: the give-up throw after the retry budget is spent on a
+ // transport error rather than a status. It reaches the caller through a different constructor
+ // call than the status path, so it needs its own assertion.
+ final int deadPort;
+ try (java.net.ServerSocket probe = new java.net.ServerSocket(0, 1,
+ java.net.InetAddress.getLoopbackAddress())) {
+ deadPort = probe.getLocalPort();
+ }
+ try (Sender sender = Sender.builder(Sender.Transport.HTTP)
+ .address("127.0.0.1:" + deadPort)
+ .protocolVersion(Sender.PROTOCOL_VERSION_V1)
+ .httpTimeoutMillis(1_000)
+ .retryTimeoutMillis(100)
+ .disableAutoFlush()
+ .build()) {
+ sender.table("t").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("expected the unreachable endpoint to surface");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("Connection Failed"));
+ Assert.assertTrue("a transport failure is transient by definition: " + e.getMessage(),
+ e.isRetryable());
+ }
+ }
+ });
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java
index 4d05487e1..27e6e5d8f 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java
@@ -53,6 +53,53 @@ public void testMessage_PutAsPrintableWithNonPrintableInput() {
}
+ @Test
+ public void testMessage_putAsPrintableEscapesBidiOverride() {
+ // U+202E RIGHT-TO-LEFT OVERRIDE is a BMP format char - regression guard for the existing behavior
+ LineSenderException e = new LineSenderException("char: ").putAsPrintable("a\u202Eb");
+ assertEquals("char: a\\u202eb", e.getMessage());
+ }
+
+ @Test
+ public void testMessage_putAsPrintableEscapesLoneSurrogate() {
+ // a lone high surrogate has no displayable meaning and must be escaped, not passed through raw
+ LineSenderException e = new LineSenderException("char: ").putAsPrintable("a\uD800b");
+ assertEquals("char: a\\ud800b", e.getMessage());
+ }
+
+ @Test
+ public void testMessage_putAsPrintableEscapesSupplementaryFormatChar() {
+ // U+E0001 LANGUAGE TAG is a supplementary-plane format char: it arrives as a surrogate pair and must
+ // be escaped (as both halves), not passed through raw, or it could hide or forge text in a log
+ String tagChar = new String(Character.toChars(0xE0001));
+ LineSenderException e = new LineSenderException("char: ").putAsPrintable("a" + tagChar + "b");
+ assertEquals("char: a\\udb40\\udc01b", e.getMessage());
+ }
+
+ @Test
+ public void testMessage_putAsPrintableKeepsEmoji() {
+ // U+1F600 GRINNING FACE is a normal supplementary char (not control or format) - emitted verbatim
+ String emoji = new String(Character.toChars(0x1F600));
+ LineSenderException e = new LineSenderException("char: ").putAsPrintable("a" + emoji + "b");
+ assertEquals("char: a" + emoji + "b", e.getMessage());
+ }
+
+ @Test
+ public void testMessage_putAsPrintableAgreesOnBothPaths() {
+ // putAsPrintable now classifies before it copies: an all-printable sequence is handed to
+ // put(CharSequence) in one go, and only a sequence carrying something unsafe is walked and escaped
+ // character by character. Two paths mean they can drift, so pin that they agree - the same text,
+ // with and without one unsafe code point in it, must differ only by that code point's escape.
+ String printable = "Could not flush buffer: table 'trades' column 'price' rejected, line 42";
+ assertEquals(printable, new LineSenderException("").putAsPrintable(printable).getMessage());
+
+ // the escaping path over the same text, with a bidi override spliced into the middle
+ int at = printable.indexOf("column");
+ String tampered = printable.substring(0, at) + (char) 0x202e + printable.substring(at);
+ assertEquals(printable.substring(0, at) + "\\u202e" + printable.substring(at),
+ new LineSenderException("").putAsPrintable(tampered).getMessage());
+ }
+
@Test
public void testMessage_withErrNo() {
LineSenderException e = new LineSenderException("message").errno(10);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/interop/ClientInteropTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/interop/ClientInteropTest.java
index 92dba65d1..16c7b5592 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/line/interop/ClientInteropTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/interop/ClientInteropTest.java
@@ -36,7 +36,6 @@
import io.questdb.client.std.Numbers;
import io.questdb.client.std.NumericException;
import io.questdb.client.std.bytes.DirectByteSink;
-import io.questdb.client.std.str.StringSink;
import io.questdb.client.test.cutlass.line.tcp.ByteChannel;
import io.questdb.client.test.tools.TestUtils;
import org.junit.Assert;
@@ -91,7 +90,6 @@ private static class JsonTestSuiteParser implements JsonParser {
public static final int TAG_TEST_NAME = 0;
private final ByteChannel byteChannel;
private final Sender sender;
- private final StringSink stringSink = new StringSink();
private int columnType = -1;
private boolean encounteredError;
private String name;
@@ -105,7 +103,7 @@ public JsonTestSuiteParser(Sender sender, ByteChannel channel) {
@Override
public void onEvent(int code, CharSequence tag, int position) throws JsonException {
- tag = unescape(tag, stringSink);
+ // JsonLexer already resolves JSON string escape sequences, so `tag` arrives fully decoded.
switch (code) {
case JsonLexer.EVT_NAME:
if (Chars.equalsIgnoreCase(tag, "testname")) {
@@ -269,70 +267,6 @@ private static boolean isTrueKeyword(CharSequence tok) {
&& (tok.charAt(3) | 32) == 'e';
}
- private static CharSequence unescape(CharSequence tag, StringSink stringSink) {
- if (tag == null) {
- return null;
- }
- stringSink.clear();
-
- for (int i = 0, n = tag.length(); i < n; i++) {
- char sourceChar = tag.charAt(i);
- if (sourceChar != '\\') {
- // happy-path, nothing to unescape
- stringSink.put(sourceChar);
- } else {
- // slow path. either there is a code unit sequence. think of this: foo\u0001bar
- // or a simple escaping: \n, \r, \\, \", etc.
- // in both cases we will consume more than 1 character from the input,
- // so we have to adjust "i" accordingly
-
- // malformed input could throw IndexOutOfBoundsException, but given we control
- // the test data then we are OK.
- char nextChar = tag.charAt(i + 1);
- if (nextChar == 'u') {
- // code unit sequence
- char ch;
- try {
- ch = (char) Numbers.parseHexInt(tag, i + 2, i + 6);
- } catch (NumericException e) {
- throw new AssertionError("cannot parse code sequence in " + tag);
- }
- stringSink.put(ch);
- i += 5;
- } else if (nextChar == '\\') {
- stringSink.put('\\');
- i++;
- } else if (nextChar == '\"') {
- stringSink.put('\"');
- i++;
- } else if (nextChar == 'b') {
- // backspace
- stringSink.put('\b');
- i++;
- } else if (nextChar == 'f') {
- // form-feed
- stringSink.put('\f');
- i++;
- } else if (nextChar == 'n') {
- // new line
- stringSink.put('\n');
- i++;
- } else if (nextChar == 'r') {
- // carriage return
- stringSink.put('\r');
- i++;
- } else if (nextChar == 't') {
- // tab
- stringSink.put('\t');
- i++;
- } else {
- throw new AssertionError("Unknown escaping sequence at " + tag);
- }
- }
- }
- return stringSink.toString();
- }
-
private void assertSuccessfulLine(byte[] tag) {
Assert.assertTrue("Produced line does not end with a new line char", byteChannel.endWith((byte) '\n'));
Assert.assertTrue("buffer base64[" + byteChannel.encodeBase64String() + "]", byteChannel.equals(tag, 0, tag.length - 1));
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientInterruptedCloseLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientInterruptedCloseLeakTest.java
new file mode 100644
index 000000000..34986a67a
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientInterruptedCloseLeakTest.java
@@ -0,0 +1,93 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.qwp.client;
+
+import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * {@link QwpQueryClient#close()} must free the I/O thread's native buffer pool and its WebSocket even
+ * when the calling thread arrives carrying an interrupt.
+ *
+ * {@code Thread.join(long)} throws {@code InterruptedException} the instant the caller's flag is set,
+ * without ever checking whether the joined thread exited. Before the fix that turned close()'s I/O-thread
+ * join into an immediate throw, taking the "could not join" return and skipping {@code closePool()} and
+ * {@code webSocketClient.close()} - a leak with no second chance, because {@code closedFlag} is CAS'd on
+ * entry and a pooled worker has already been removed from {@code QueryClientPool.all} by the reap that
+ * called it.
+ *
+ * The path is real rather than theoretical: {@code PoolHousekeeper.stop()} interrupts the housekeeper
+ * thread to break a recovery build's credential pull, and that same thread runs {@code
+ * queryPool.reapIdle()} immediately afterwards with the flag still set.
+ *
+ * {@code assertMemoryLeak} is the assertion - it compares native memory per tag around the body, so a
+ * skipped {@code closePool()} fails the test. The interrupt-preserved check guards the other half of the
+ * contract: taking the flag out of the way must not swallow the caller's cancellation.
+ */
+public class QwpQueryClientInterruptedCloseLeakTest {
+
+ @Test(timeout = 30_000)
+ public void testCloseFreesNativeResourcesWhenTheCallerCarriesAnInterrupt() throws Exception {
+ try {
+ assertMemoryLeak(() -> {
+ TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ });
+ server.setSendServerInfo(true);
+ try {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ QwpQueryClient client = QwpQueryClient.fromConfig(
+ "ws::addr=localhost:" + server.getPort() + ";auth_timeout_ms=2000;");
+ try {
+ client.connect();
+ Assert.assertTrue("the client must bind the endpoint, or there is no I/O thread "
+ + "and no buffer pool for this test to observe", client.isConnected());
+
+ // Arrive at close() already interrupted, exactly as the housekeeper does after
+ // stop() escalates.
+ Thread.currentThread().interrupt();
+ } finally {
+ client.close();
+ }
+
+ Assert.assertTrue("close() must hand the caller's cancellation back, not swallow it",
+ Thread.currentThread().isInterrupted());
+ } finally {
+ server.close();
+ }
+ });
+ } finally {
+ // Never let the flag escape into the next test on this thread.
+ Thread.interrupted();
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
index a220084ab..98235ed9e 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
@@ -51,6 +51,8 @@ public void testAllSettersRejectAfterConnect() throws Exception {
assertRejects(c -> c.withBasicAuth("u", "p"), "withBasicAuth");
// withBearerToken
assertRejects(c -> c.withBearerToken("tok"), "withBearerToken");
+ // withBearerTokenProvider
+ assertRejects(c -> c.withBearerTokenProvider(() -> "tok"), "withBearerTokenProvider");
// withBufferPoolSize
assertRejects(c -> c.withBufferPoolSize(2), "withBufferPoolSize");
// withClientId
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java
new file mode 100644
index 000000000..e66e0e5ba
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java
@@ -0,0 +1,382 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.qwp.client;
+
+import io.questdb.client.cutlass.auth.OidcAuthException;
+import io.questdb.client.cutlass.http.client.HttpClientException;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.QwpColumnBatch;
+import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler;
+import io.questdb.client.cutlass.qwp.client.QwpEgressMsgKind;
+import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.test.cutlass.auth.MockOidcServer;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.HandOffCharSequence;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Unit coverage for {@link QwpQueryClient#withBearerTokenProvider}: header
+ * synthesis, re-query at each resolve (a fresh token per WebSocket upgrade),
+ * token validation, null rejection, and mutual exclusion with the fixed-token
+ * and basic-auth setters - exercised both through
+ * {@link QwpQueryClient#getAuthorizationHeaderForTest()} (which resolves the
+ * header the same way a real upgrade does) and, for the real connect path,
+ * against a loopback mock that captures the upgrade's {@code Authorization}
+ * header and confirms a throwing provider fails the connection attempt. The
+ * post-connect guard for the setter lives in
+ * {@link QwpQueryClientPostConnectGuardTest}.
+ *
+ * Every test runs under {@code assertMemoryLeak}: a {@link QwpQueryClient}
+ * mallocs native scratch in its constructor, so each case proves that scratch
+ * is freed on close, including on the connect/error paths.
+ */
+public class QwpQueryClientTokenProviderTest {
+
+ private static final QwpColumnBatchHandler NOOP_BATCH_HANDLER = new QwpColumnBatchHandler() {
+ @Override
+ public void onBatch(QwpColumnBatch batch) {
+ }
+
+ @Override
+ public void onEnd(long totalRows) {
+ }
+
+ @Override
+ public void onError(byte status, String message) {
+ }
+ };
+
+ @Test
+ public void testProviderBufferMutatedDuringResolveCannotSplice() throws Exception {
+ assertMemoryLeak(() -> {
+ // resolveAuthorizationHeader snapshots the pulled value before validating it, so the bytes that
+ // are checked are the bytes that are sent. Without the snapshot validateToken scans the
+ // provider's live sequence and the "Bearer " concatenation then materialises it a second time -
+ // two reads of a buffer HttpTokenProvider explicitly invites a provider to reuse. A mutation
+ // landing between them passes the check and splices CR/LF into the upgrade header.
+ //
+ // The ILP sender's copy of this rule is pinned by
+ // LineHttpSenderTokenProviderTest.testTokenMutatedBetweenValidationAndTheHeaderCannotSplice;
+ // this is the same rule at the query client's callsite, which had no test.
+ final String clean = "GOODTOKEN";
+ final String spliced = "abc" + (char) 0x0d + (char) 0x0a + "X-Injected: pwned";
+ AtomicInteger pulls = new AtomicInteger();
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)
+ .withBearerTokenProvider(() -> {
+ pulls.incrementAndGet();
+ return new HandOffCharSequence(clean, spliced);
+ })) {
+ Assert.assertEquals("the header must carry the bytes that were validated, not a value "
+ + "swapped in after the scan", "Bearer " + clean, c.getAuthorizationHeaderForTest());
+ Assert.assertEquals("the provider must have been queried, or this test passes for the "
+ + "wrong reason", 1, pulls.get());
+ }
+ });
+ }
+
+ @Test
+ public void testOidcProviderFailureIsWrappedAsLineSenderException() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)
+ .withBearerTokenProvider(() -> {
+ throw new OidcAuthException("the cached token could not be refreshed");
+ })) {
+ try {
+ c.getAuthorizationHeaderForTest();
+ Assert.fail("an OIDC provider failure must fail token resolution");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("the cached token could not be refreshed"));
+ Assert.assertTrue("the provider failure must be retained as the cause",
+ e.getCause() instanceof OidcAuthException);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testProviderConflictsWithBasicAuth() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerTokenProvider(() -> "tok")) {
+ try {
+ c.withBasicAuth("u", "p");
+ Assert.fail("withBasicAuth after withBearerTokenProvider must throw");
+ } catch (IllegalStateException expected) {
+ // mutually exclusive
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testProviderConflictsWithBearerToken() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerTokenProvider(() -> "tok")) {
+ try {
+ c.withBearerToken("other");
+ Assert.fail("withBearerToken after withBearerTokenProvider must throw");
+ } catch (IllegalStateException expected) {
+ // mutually exclusive
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testProviderNullOrBlankReturnRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ // validateToken rejects a null, empty or blank token RETURNED by the provider before it reaches the
+ // "Bearer " header (distinct from testProviderNullRejected, which rejects a null provider at the setter)
+ String[] bad = {null, "", " "};
+ for (String token : bad) {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)
+ .withBearerTokenProvider(() -> token)) {
+ try {
+ c.getAuthorizationHeaderForTest();
+ Assert.fail("a null/empty/blank provider token must be rejected, was: [" + token + ']');
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("null or empty"));
+ }
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testProviderNullRejected() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)) {
+ try {
+ c.withBearerTokenProvider(null);
+ Assert.fail("a null provider must be rejected");
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testProviderQueriedAtEachResolve() throws Exception {
+ assertMemoryLeak(() -> {
+ int[] counter = {0};
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)
+ .withBearerTokenProvider(() -> "tok-" + (counter[0]++))) {
+ // each resolve re-queries the provider, so a reconnect presents a fresh token
+ Assert.assertEquals("Bearer tok-0", c.getAuthorizationHeaderForTest());
+ Assert.assertEquals("Bearer tok-1", c.getAuthorizationHeaderForTest());
+ }
+ });
+ }
+
+ @Test
+ public void testProviderSynthesizesBearerHeader() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)
+ .withBearerTokenProvider(() -> "abc123")) {
+ Assert.assertEquals("Bearer abc123", c.getAuthorizationHeaderForTest());
+ }
+ });
+ }
+
+ @Test(timeout = 20_000)
+ public void testProviderTokenReResolvedOnFailoverReconnect() throws Exception {
+ assertMemoryLeak(() -> {
+ // The failover reconnect path (reconnectViaTracker) resolves the Authorization header once before its
+ // endpoint walk, exactly as connect() does, so a rotating token reaches the reconnect upgrade. This
+ // pins that a regression dropping the re-resolve from the reconnect path would be caught: bind endpoint
+ // A on the initial connect (capturing tok-0), drop it, then run a query - the failover reconnect to
+ // endpoint B must upgrade with a FRESHLY resolved token, not the stale connect-time one.
+ AtomicInteger calls = new AtomicInteger();
+ TestWebSocketServer a = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ });
+ a.setSendServerInfo(true);
+ TestWebSocketServer b = new TestWebSocketServer(new ExecDoneQueryServer());
+ b.setSendServerInfo(true);
+ try {
+ a.start();
+ b.start();
+ Assert.assertTrue(a.awaitStart(5, TimeUnit.SECONDS));
+ Assert.assertTrue(b.awaitStart(5, TimeUnit.SECONDS));
+
+ try (QwpQueryClient client = QwpQueryClient.fromConfig(
+ "ws::addr=localhost:" + a.getPort() + ",localhost:" + b.getPort() + ";auth_timeout_ms=2000;")
+ .withBearerTokenProvider(() -> "tok-" + calls.getAndIncrement())) {
+ client.connect();
+ Assert.assertTrue("client must bind the first endpoint on connect", client.isConnected());
+ String aHeader = a.pollAuthorizationHeader(5, TimeUnit.SECONDS);
+ Assert.assertEquals("the initial connect upgrade must carry the first resolved token",
+ "Bearer tok-0", aHeader);
+
+ // drop endpoint A so the next execute() cannot use its connection and must fail over
+ a.close();
+
+ // the query fails on the dead A connection, drives the failover loop -> reconnectViaTracker,
+ // which re-resolves the header and upgrades B; B answers EXEC_DONE so execute() returns
+ client.execute("SELECT 1", NOOP_BATCH_HANDLER, false);
+
+ String bHeader = b.pollAuthorizationHeader(5, TimeUnit.SECONDS);
+ Assert.assertNotNull("the failover reconnect must upgrade endpoint B", bHeader);
+ Assert.assertTrue("the reconnect upgrade must carry a Bearer token, was: " + bHeader,
+ bHeader.startsWith("Bearer tok-"));
+ Assert.assertNotEquals("the failover reconnect must RE-RESOLVE the provider, not reuse the "
+ + "connect-time token", aHeader, bHeader);
+ }
+ } finally {
+ a.close();
+ b.close();
+ }
+ });
+ }
+
+ @Test(timeout = 15_000)
+ public void testProviderTokenSentOnRealUpgrade() throws Exception {
+ assertMemoryLeak(() -> {
+ // drive the REAL connect path (connect() -> resolveAuthorizationHeader -> runUpgradeWithTimeout),
+ // not the test hook: the upgrade request must carry the freshly pulled "Bearer ". The mock
+ // answers 404 (not auth-failed, not terminal) so connect() fails fast after the header was sent.
+ // MockOidcServer is the harness for this: it records the Authorization header of every request it
+ // reads and resurfaces a handler throwable on close(), where the hand-rolled listener this
+ // replaced swallowed every harness fault into `catch (Exception ignored)` - so an accept, read or
+ // write that broke arrived as a MISSING header, i.e. as a product regression.
+ try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(404, ""));
+ QwpQueryClient client = QwpQueryClient
+ .fromConfig("ws::addr=127.0.0.1:" + server.port() + ";failover=off;target=any;")
+ .withBearerTokenProvider(() -> "tok-0")) {
+ try {
+ client.connect();
+ Assert.fail("expected connect to fail on a 404 upgrade");
+ } catch (HttpClientException expected) {
+ // 404 is neither auth-failed nor terminal: the endpoint is exhausted and connect() fails -
+ // but the upgrade request already carried the Bearer header captured below
+ }
+ List authHeaders = server.requestAuthHeaders();
+ Assert.assertEquals("the provider's token must reach the real upgrade request",
+ 1, authHeaders.size());
+ Assert.assertEquals("Bearer tok-0", authHeaders.get(0));
+ }
+ });
+ }
+
+ @Test
+ public void testProviderTokenValidated() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)
+ .withBearerTokenProvider(() -> "bad\ntoken")) {
+ try {
+ c.getAuthorizationHeaderForTest();
+ Assert.fail("a token carrying a control character must be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("control or non-ASCII"));
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testSettingBearerTokenThenProviderConflicts() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerToken("tok")) {
+ try {
+ c.withBearerTokenProvider(() -> "other");
+ Assert.fail("withBearerTokenProvider after withBearerToken must throw");
+ } catch (IllegalStateException expected) {
+ // mutually exclusive
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 10_000)
+ public void testThrowingProviderFailsConnect() throws Exception {
+ assertMemoryLeak(() -> {
+ // a provider that throws must fail the connection attempt on the REAL connect path:
+ // resolveAuthorizationHeader runs once before the endpoint walk, so the throw propagates straight
+ // out of connect() as the provider's own error (not wrapped as "all endpoints unreachable")
+ try (
+ ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
+ QwpQueryClient client = QwpQueryClient.fromConfig(
+ "ws::addr=127.0.0.1:" + listener.getLocalPort() + ";failover=off;target=any;"
+ ).withBearerTokenProvider(() -> {
+ throw new LineSenderException("provider down");
+ })
+ ) {
+ try {
+ client.connect();
+ Assert.fail("a throwing provider must fail the connection attempt");
+ } catch (RuntimeException expected) {
+ // the provider's own exception propagates directly (the header is resolved before the
+ // endpoint walk), not wrapped as a transport "all endpoints unreachable" error
+ Assert.assertTrue(expected.getClass().getName(), expected instanceof LineSenderException);
+ Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("provider down"));
+ Assert.assertFalse(expected.getMessage(), expected.getMessage().contains("unreachable"));
+ }
+ }
+ });
+ }
+
+ private static byte[] buildExecDone(byte[] queryRequest) {
+ int bodyLen = 1 + 8 + 1 + 1; // msg_kind + request_id + op_type + rows_affected varint
+ byte[] frame = new byte[QwpConstants.HEADER_SIZE + bodyLen];
+ ByteBuffer bb = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 'Q').put((byte) 'W').put((byte) 'P').put((byte) '1');
+ bb.put((byte) 1); // version
+ bb.put((byte) 0); // flags
+ bb.putShort((short) 0); // table_count
+ bb.putInt(bodyLen); // payload_length
+ bb.put(QwpEgressMsgKind.EXEC_DONE);
+ bb.put(queryRequest, 1, 8); // echo request_id verbatim
+ bb.put((byte) 0); // op_type
+ bb.put((byte) 0); // rows_affected = 0
+ return frame;
+ }
+
+ private static final class ExecDoneQueryServer implements TestWebSocketServer.WebSocketServerHandler {
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ if (data.length == 0 || data[0] != QwpEgressMsgKind.QUERY_REQUEST) {
+ return;
+ }
+ try {
+ client.sendBinary(buildExecDone(data));
+ } catch (IOException e) {
+ // best-effort: a failed reply surfaces to the client as a transport error
+ }
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java
index f16bc2670..0d94377d6 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java
@@ -880,6 +880,26 @@ public void testCloseDropsInProgressRowButFlushesCommittedRows() throws Exceptio
});
}
+ @Test
+ public void testColumnRejectsInvalidCharactersAreEscaped() throws Exception {
+ assertMemoryLeak(() -> {
+ CapturingNetworkFacade nf = new CapturingNetworkFacade();
+ try (QwpUdpSender sender = new QwpUdpSender(nf, 0, 0, 9000, 1)) {
+ // a rejected COLUMN name carrying a display-unsafe char must be ESCAPED in the message, not
+ // spliced in raw (M6 parity with the ILP name/error render, at the QwpTableBuffer layer)
+ try {
+ sender.table("t").longColumn("bad" + (char) 0x01 + "col", 1L);
+ Assert.fail("expected an illegal column name to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue("the control char must be escaped: " + e.getMessage(),
+ e.getMessage().contains("\\u0001"));
+ Assert.assertTrue("a raw control char must not leak into the message",
+ e.getMessage().indexOf((char) 0x01) < 0);
+ }
+ }
+ });
+ }
+
@Test
public void testDuplicateColumnAfterSchemaFlushReplayIsRejected() throws Exception {
assertMemoryLeak(() -> {
@@ -1715,6 +1735,19 @@ public void testTableRejectsInvalidCharacters() throws Exception {
sender.table(".leading_dot")
);
+ // a rejected name carrying a display-unsafe char (here a control char; also bidi/BOM/zero-width)
+ // must be ESCAPED in the message, not spliced in raw where it could reorder, hide or forge what a
+ // human reads - M6 parity with the ILP name/error render
+ try {
+ sender.table("bad" + (char) 0x01 + "name");
+ Assert.fail("expected an illegal table name to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue("the control char must be escaped: " + e.getMessage(),
+ e.getMessage().contains("\\u0001"));
+ Assert.assertTrue("a raw control char must not leak into the message",
+ e.getMessage().indexOf((char) 0x01) < 0);
+ }
+
// Sender must remain usable after rejected names
sender.table("valid")
.longColumn("x", 1)
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
index b1e21870c..3911f5c05 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
@@ -833,6 +833,30 @@ private static void assertClosed(Runnable r) {
}
}
+ @Test
+ public void testIllegalTableNameIsEscapedInTheMessage() throws Exception {
+ assertMemoryLeak(() -> {
+ // A rejected table name is attacker-influenced text on its way to a log line or a terminal, so
+ // it is escaped rather than concatenated. Three of the four callsites that do this are covered -
+ // QwpUdpSender and QwpTableBuffer by QwpUdpSenderTest, the ILP names by AbstractLineSender's
+ // tests - and this one, the WebSocket sender's own check, was not: its message could regress to
+ // a raw concatenation with the suite still green.
+ try (QwpWebSocketSender sender = createUnconnectedSender()) {
+ try {
+ sender.table("bad" + (char) 0x01 + "name");
+ Assert.fail("an illegal table name must be rejected");
+ } catch (LineSenderException e) {
+ final String message = e.getMessage();
+ Assert.assertTrue(message, message.contains("table name contains illegal characters"));
+ Assert.assertTrue("the offending char must be escaped: " + message,
+ message.contains("\\u0001"));
+ Assert.assertTrue("the raw control char must not reach the message",
+ message.indexOf(0x01) < 0);
+ }
+ }
+ });
+ }
+
private static MicrobatchBuffer getMicrobatchBuffer(QwpWebSocketSender sender, String fieldName) throws Exception {
Field field = QwpWebSocketSender.class.getDeclaredField(fieldName);
field.setAccessible(true);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java
index b777c8a87..bb200682c 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java
@@ -113,6 +113,58 @@ public void testSlotLockReleasedAfterCleanClose() throws Exception {
});
}
+ /**
+ * Interrupt-neutrality: a CARRIED interrupt flag must not turn a healthy {@code close()} into a
+ * failed stop.
+ *
+ * {@code PoolHousekeeper.stop()} and {@code SenderPool.stopStartupRecoveryDriver()} escalate to
+ * {@code Thread.interrupt()} when their join times out, and the thread they interrupt is the same one
+ * that then runs {@code senderPool.reapIdle()} and the startup-recovery step's {@code finally} --
+ * both of which close a delegate. That makes a carried flag ordinary on this path rather than exotic.
+ *
+ * It is also fatal if unhandled: {@code CountDownLatch.await(t, u)} tests {@code Thread.interrupted()}
+ * before it ever consults the latch, so the shutdown await returns instantly, {@code close()} takes the
+ * failed-stop branch, and the slot is reported as still flocked -- the exact outcome the interrupt was
+ * added to prevent. The failed-stop branch re-asserts the flag, so in a reap sweep every remaining
+ * delegate failed the same way.
+ *
+ * Asserted on both halves, because clearing the flag and forgetting to restore it would pass a
+ * released-lock check while silently eating the caller's cancellation.
+ */
+ @Test
+ public void testCarriedInterruptNeitherFailsCloseNorRetainsTheSlotLock() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ String cfg = "ws::addr=localhost:" + port + ";close_flush_timeout_millis=2000;";
+ QwpWebSocketSender wss = (QwpWebSocketSender) Sender.fromConfig(cfg);
+ wss.table("t").longColumn("v", 1L).atNow();
+ wss.flush();
+
+ final boolean flagSurvived;
+ Thread.currentThread().interrupt();
+ try {
+ wss.close();
+ flagSurvived = Thread.currentThread().isInterrupted();
+ } finally {
+ // never let it escape into the next test on a reused JUnit thread
+ Thread.interrupted();
+ }
+
+ Assert.assertTrue(
+ "close() must restore the caller's interrupt flag, not consume it",
+ flagSurvived);
+ Assert.assertTrue(
+ "a carried interrupt must not make a healthy close() report its slot lock retained",
+ wss.isSlotLockReleased());
+ }
+ });
+ }
+
+
/**
* Leak path: when {@code close()} cannot wind the I/O loop down it bails
* out via the {@code !ioThreadStopped} early-return and must leave the slot
@@ -368,12 +420,16 @@ public void testDelegatedIoThreadEngineCloseFlipsSlotLockReleased() throws Excep
wss.setCursorEngine(engine, true);
wss.setCursorSendLoopForTesting(loop);
- // Drive the real early-bail close() on a thread whose pending
- // interrupt lands in loop.close()'s shutdownLatch.await().
+ // Drive the real early-bail close() through the loop's own bounded-await backstop.
+ // NOT by handing the closer a pending interrupt: close() is interrupt-neutral (it clears a
+ // CARRIED flag and restores it on the way out), because the pool threads that close
+ // delegates are the same ones PoolHousekeeper.stop() interrupts. Shrinking the backstop
+ // reaches the same failed-stop branch deterministically and without a 30s wait, which is
+ // exactly what this seam exists for.
+ loop.setShutdownAwaitTimeoutMillis(200L);
AtomicReference closeFailure = new AtomicReference<>();
QwpWebSocketSender wssRef = wss;
Thread closer = new Thread(() -> {
- Thread.currentThread().interrupt();
try {
wssRef.close();
} catch (Throwable t) {
@@ -524,9 +580,11 @@ public void testFailedIoStopReclaimsSenderResourcesAfterWorkerExit() throws Exce
Assert.assertNotNull(errorDispatcherThread);
Assert.assertNotNull(progressDispatcherThread);
+ // Bounded-await backstop rather than a pending interrupt on the closer -- see the sibling
+ // test above: close() is interrupt-neutral, so a carried flag no longer short-circuits it.
+ loop.setShutdownAwaitTimeoutMillis(200L);
AtomicReference closeFailure = new AtomicReference<>();
Thread closer = new Thread(() -> {
- Thread.currentThread().interrupt();
try {
sender.close();
} catch (Throwable t) {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketCredentialCancellationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketCredentialCancellationTest.java
new file mode 100644
index 000000000..82cf08ea0
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketCredentialCancellationTest.java
@@ -0,0 +1,366 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.qwp.client;
+
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.auth.FileTokenStore;
+import io.questdb.client.cutlass.auth.OidcDeviceAuth;
+import io.questdb.client.cutlass.auth.PersistedToken;
+import io.questdb.client.cutlass.auth.TokenStoreKey;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.test.tools.NoBrowserLaunch;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLockContentionException;
+import io.questdb.client.std.ObjList;
+import io.questdb.client.test.cutlass.auth.MockOidcServer;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Connect cancellation against the BUILT-IN credential path — a real {@link OidcDeviceAuth} over a real
+ * {@link FileTokenStore} — rather than a test double.
+ *
+ * QWP's close() cannot reach a credential pull through {@code closeTraffic()}: the pull is caller code
+ * owning no socket. Its only lever is an interrupt, which
+ * {@code CursorWebSocketSendLoop.ConnectCancellation.cancel()} sends to the thread published as being
+ * inside the pull. Whether that lever WORKS depends entirely on what the pull is blocked in, and every
+ * existing test blocks it in an interruptible test double, so the shipped path went unchecked: it waited
+ * on an uninterruptible {@code ReentrantLock.lock()} and polled the lock file through {@code Os.sleep},
+ * which catches {@code InterruptedException} and keeps sleeping to its own deadline. The lock-acquire
+ * budget caps at 30s, the same as close()'s shutdown budget, so a sender closing while another
+ * same-identity instance held the lock burned the whole budget and then gave up on its I/O thread,
+ * delegating teardown of the native client, the cursor engine and the store-and-forward slot lock.
+ *
+ * The token endpoint is never reached in these tests: the pull cannot get past the store lock. That is
+ * asserted, because reaching it would mean the wait had already been abandoned for a lock-free refresh
+ * and the test would no longer be exercising the blocked path.
+ */
+public class WebSocketCredentialCancellationTest {
+ private static final String DEVICE_PATH = "/device";
+ // Issued lifetime and remaining life of the seeded entry. effectiveSkewMillis caps the clock-skew
+ // margin at half the issued lifetime, so this reads as valid for (12s - 10s) = ~2s and stale after:
+ // long enough for the foreground connect to be a cache hit, short enough to force the reconnect's pull
+ // into a refresh without stubbing the clock.
+ private static final long SEED_REMAINING_MILLIS = 12_000L;
+ private static final long SEED_TTL_MILLIS = 20_000L;
+ private static final String TOKEN_PATH = "/token";
+
+ // the credential pull can reach the device-code prompt; see NoBrowserLaunch for why this is a rule
+ @ClassRule
+ public static final NoBrowserLaunch NO_BROWSER = new NoBrowserLaunch();
+
+ @Rule
+ public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test(timeout = 120_000)
+ public void testCloseBreaksAForegroundReconnectBlockedInTheBuiltInCredentialPull() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger tokenEndpointCalls = new AtomicInteger();
+ try (MockOidcServer idp = new MockOidcServer((method, path, body) -> {
+ if (TOKEN_PATH.equals(path)) {
+ tokenEndpointCalls.incrementAndGet();
+ }
+ return MockOidcServer.json(200, "{}");
+ })) {
+ Path dir = storeDir();
+ Files.createDirectories(dir);
+ // The maximum permitted acquire budget, which is exactly QWP's close() shutdown budget: this
+ // is the wait an interrupt has to be able to cut short.
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000);
+ TokenStoreKey key = keyFor(idp);
+ store.save(key, seededEntry());
+
+ DropAfterFirstAckHandler wire = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(wire)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ AtomicBoolean armed = new AtomicBoolean();
+ CountDownLatch blockedPullStarted = new CountDownLatch(1);
+ try (OidcDeviceAuth auth = authFor(idp, store)) {
+ Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + server.getPort())
+ .reconnectInitialBackoffMillis(20)
+ .reconnectMaxBackoffMillis(20)
+ .httpTokenProvider(() -> {
+ // The wrapper only reports that a pull started; the blocking is all done by
+ // the real OidcDeviceAuth/FileTokenStore underneath.
+ if (armed.get()) {
+ blockedPullStarted.countDown();
+ }
+ return auth.getToken();
+ })
+ .build();
+ boolean closed = false;
+ try {
+ // the seeded entry is still valid, so the foreground connect is a cache hit and
+ // never touches the store lock
+ Assert.assertEquals("Bearer ACCESS-SEED",
+ server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // A peer holds the identity's lock: stamped (so the empty-lock grace does not
+ // apply) and far inside the staleness window (so it is never stolen). Every later
+ // refresh can only poll for it.
+ Path lock = dir.resolve(key.hash() + ".lock");
+ Files.write(lock, "live-peer-nonce".getBytes(StandardCharsets.UTF_8));
+
+ armed.set(true);
+ // let the seeded token fall inside its clock-skew margin, so the reconnect's pull
+ // has to refresh rather than serve the cache
+ // stale once now >= expiresAt - skew, i.e. after (remaining - ttl/2)
+ Thread.sleep(SEED_REMAINING_MILLIS - SEED_TTL_MILLIS / 2 + 500L);
+
+ // one batch: the server acks it, then drops the socket -> foreground reconnect ->
+ // credential pull -> refresh -> blocked polling for the store lock
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ Assert.assertTrue("the reconnect must reach the credential pull",
+ blockedPullStarted.await(30, TimeUnit.SECONDS));
+
+ long startNanos = System.nanoTime();
+ sender.close();
+ closed = true;
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ // the shutdown budget is 30s; anything near it means close() waited the store lock
+ // out instead of breaking it. A generous ceiling keeps this off the CI flake line
+ // while still failing the pre-fix behaviour by a wide margin.
+ Assert.assertTrue("close() must break the store-lock wait, not sit out the shutdown "
+ + "budget; took " + elapsedMillis + "ms", elapsedMillis < 15_000);
+ Assert.assertEquals("the pull never got past the store lock, so the IdP must not "
+ + "have been reached", 0, tokenEndpointCalls.get());
+ } finally {
+ if (!closed) {
+ sender.close();
+ }
+ }
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 120_000)
+ public void testCloseStopsAnOrphanDrainerBlockedInTheBuiltInCredentialPull() throws Exception {
+ assertMemoryLeak(() -> {
+ // The orphan drainer's INITIAL connect -- the one it makes before any CursorWebSocketSendLoop
+ // exists, so before the loop's ConnectCancellation is in play. Its lever is a different one:
+ // BackgroundDrainerPool.close() ends its stop grace with executor.shutdownNow(), which
+ // interrupts the drainer thread. That interrupt only accomplishes anything if what the thread
+ // is blocked in honours it, and a credential pull sat in the token store's uninterruptible
+ // waits -- so the drainer sat out the store's whole 30s lock-acquire budget while close() gave
+ // up on it, leaving the orphan slot locked by a thread nobody was waiting for any more.
+ String sfDir = temp.getRoot().toPath().resolve("sf").toString();
+ AtomicInteger tokenEndpointCalls = new AtomicInteger();
+ try (MockOidcServer idp = new MockOidcServer((method, path, body) -> {
+ if (TOKEN_PATH.equals(path)) {
+ tokenEndpointCalls.incrementAndGet();
+ }
+ return MockOidcServer.json(200, "{}");
+ })) {
+ // Phase 1: a ghost sender leaves un-acked frames behind, so phase 2 has an orphan to adopt.
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ try (Sender ghost = Sender.fromConfig("ws::addr=localhost:" + silent.getPort()
+ + ";sf_dir=" + sfDir + ";sender_id=ghost;close_flush_timeout_millis=0;")) {
+ ghost.table("foo").longColumn("v", 7L).atNow();
+ ghost.flush();
+ }
+ }
+ ObjList orphans = OrphanScanner.scan(sfDir, "primary");
+ Assert.assertEquals("phase 1 must leave exactly one orphan slot", 1, orphans.size());
+
+ Path dir = storeDir();
+ Files.createDirectories(dir);
+ FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000);
+ TokenStoreKey key = keyFor(idp);
+ // No access token, only a refresh token: adopt() keeps the refresh token and leaves the cache
+ // empty, so EVERY pull -- the foreground's and the drainer's initial one alike -- goes into
+ // inLock rather than hitting a cache.
+ store.save(key, new PersistedToken(null, null, "REFRESH-SEED", 0L, 0L));
+ // the peer's live lock is in place BEFORE the sender is built, so the drainer's very first
+ // connect blocks
+ Files.write(dir.resolve(key.hash() + ".lock"), "live-peer-nonce".getBytes(StandardCharsets.UTF_8));
+
+ try (TestWebSocketServer server = new TestWebSocketServer(new SilentHandler())) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ CountDownLatch drainerPullStarted = new CountDownLatch(1);
+ try (OidcDeviceAuth auth = authFor(idp, store)) {
+ // ASYNC so build() itself does not sit in the blocked foreground pull and never hand
+ // back a sender to close; the orphan drainers still start inside build().
+ Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + server.getPort())
+ .storeAndForwardDir(sfDir)
+ .senderId("primary")
+ .drainOrphans(true)
+ .initialConnectMode(Sender.InitialConnectMode.ASYNC)
+ .reconnectInitialBackoffMillis(20)
+ .reconnectMaxBackoffMillis(20)
+ .httpTokenProvider(() -> {
+ // positively confirm the DRAINER (not just the foreground loop) reaches
+ // the pull; without this the test could pass on a drainer that never
+ // started and prove nothing about the initial-connect path
+ if (Thread.currentThread().getName().contains("orphan-drainer")) {
+ drainerPullStarted.countDown();
+ }
+ return auth.getToken();
+ })
+ .build();
+ boolean closed = false;
+ try {
+ Assert.assertTrue("the orphan drainer must reach its initial credential pull",
+ drainerPullStarted.await(30, TimeUnit.SECONDS));
+ // let it settle into the store's lock wait
+ Thread.sleep(500L);
+
+ long startNanos = System.nanoTime();
+ sender.close();
+ closed = true;
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ Assert.assertTrue("close() must stop a drainer blocked in the built-in credential "
+ + "pull, not leave it to the store's 30s budget; took " + elapsedMillis
+ + "ms", elapsedMillis < 15_000);
+ } finally {
+ if (!closed) {
+ sender.close();
+ }
+ }
+ }
+ Assert.assertEquals("the pull never got past the store lock, so the IdP must not have "
+ + "been reached", 0, tokenEndpointCalls.get());
+ // The drainer must have released the orphan slot's lock on the way out: a slot still
+ // locked by an abandoned drainer thread cannot be adopted by anyone, which is the durable
+ // cost of close() giving up on it.
+ Assert.assertTrue("the abandoned orphan slot must be adoptable again after close()",
+ awaitSlotAdoptable(sfDir + "/ghost", 10_000));
+ }
+ }
+ });
+ }
+
+ private static OidcDeviceAuth authFor(MockOidcServer idp, FileTokenStore store) {
+ return OidcDeviceAuth.builder()
+ .clientId("questdb")
+ .deviceAuthorizationEndpoint(idp.httpUrl(DEVICE_PATH))
+ .tokenEndpoint(idp.httpUrl(TOKEN_PATH))
+ .scope("openid")
+ .allowInsecureTransport(true)
+ .tokenStore(store)
+ .prompt(challenge -> {
+ })
+ .build();
+ }
+
+ private static TokenStoreKey keyFor(MockOidcServer idp) {
+ return new TokenStoreKey(
+ "questdb",
+ idp.httpUrl(TOKEN_PATH),
+ idp.httpUrl(DEVICE_PATH),
+ "openid",
+ null,
+ false);
+ }
+
+ private static PersistedToken seededEntry() {
+ // an access token the foreground connect can serve straight from cache, plus the refresh token that
+ // sends the next pull into inLock once it goes stale
+ return new PersistedToken("ACCESS-SEED", null, "REFRESH-SEED",
+ System.currentTimeMillis() + SEED_REMAINING_MILLIS, SEED_TTL_MILLIS);
+ }
+
+ private static boolean awaitSlotAdoptable(String slotPath, long timeoutMillis) throws Exception {
+ // the slot lock is an flock held by the drainer's engine, so a fresh acquire succeeds only once the
+ // drainer has genuinely let go; SlotLock.acquire throws SlotLockContentionException while it has not
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ try (SlotLock probe = SlotLock.acquire(slotPath)) {
+ Assert.assertNotNull(probe);
+ return true;
+ } catch (SlotLockContentionException e) {
+ Thread.sleep(50);
+ }
+ }
+ return false;
+ }
+
+ private Path storeDir() {
+ return temp.getRoot().toPath().resolve("oidc-tokens");
+ }
+
+ /** Never acks, so a sender's frames stay un-acked on disk. */
+ private static final class SilentHandler implements TestWebSocketServer.WebSocketServerHandler {
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ }
+ }
+
+ /**
+ * Acks the first binary frame, then closes the socket — a deterministic drop that drives the foreground
+ * loop into a reconnect, and so into a fresh credential pull.
+ */
+ private static final class DropAfterFirstAckHandler implements TestWebSocketServer.WebSocketServerHandler {
+ private final AtomicInteger received = new AtomicInteger();
+
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ int n = received.incrementAndGet();
+ try {
+ if (n == 1) {
+ client.sendBinary(okFrame(0L));
+ client.close();
+ }
+ } catch (IOException ignored) {
+ // best-effort: the connection died under us
+ }
+ }
+
+ private static byte[] okFrame(long wireSeq) {
+ ByteBuffer bb = ByteBuffer.allocate(1 + 8 + 2).order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x00); // STATUS_OK
+ bb.putLong(wireSeq);
+ bb.putShort((short) 0);
+ return bb.array();
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java
new file mode 100644
index 000000000..6dbd2bd29
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java
@@ -0,0 +1,758 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.qwp.client;
+
+import io.questdb.client.Sender;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.auth.OidcAuthException;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.HandOffCharSequence;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Verifies that the WebSocket (QWP) transport accepts an
+ * {@link Sender.LineSenderBuilder#httpTokenProvider} and presents the provider's current token as the
+ * {@code Authorization: Bearer} header on every upgrade handshake - the initial connect and each
+ * reconnect - so a long-lived WebSocket sender follows token rotation the way the HTTP transport does.
+ * The provider is queried at handshake time, not per data frame, because an established WebSocket is
+ * not re-authenticated mid-stream. The fixed-token and username/password paths are covered too as a
+ * regression guard for the refactor that turned the captured header string into a per-handshake supplier.
+ *
+ * Each test runs under {@code assertMemoryLeak} so the sender's native buffers are proven freed on close.
+ */
+public class WebSocketTokenProviderTest {
+
+ @Test(timeout = 30_000)
+ public void testProviderBufferMutatedDuringTheHandshakeCannotSplice() throws Exception {
+ assertMemoryLeak(() -> {
+ // Sender.buildWebSocketAuthHeader's supplier applies the same snapshot-before-validate rule as
+ // the ILP sender and the query client, and was the one of the three with no test. Without the
+ // snapshot validateToken scans the provider's live sequence and the "Bearer " concatenation
+ // materialises it again, so a buffer that changes between those two reads ships the mutated
+ // bytes - CR/LF included - into the upgrade request.
+ final String clean = "GOODTOKEN";
+ final String spliced = "abc" + (char) 0x0d + (char) 0x0a + "X-Injected: pwned";
+ AtomicInteger pulls = new AtomicInteger();
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + server.getPort())
+ .httpTokenProvider(() -> {
+ pulls.incrementAndGet();
+ return new HandOffCharSequence(clean, spliced);
+ })
+ .build()) {
+ Assert.assertNotNull(sender);
+ Assert.assertEquals("the upgrade must carry the bytes that were validated, not a value "
+ + "swapped in after the scan",
+ "Bearer " + clean, server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+ Assert.assertTrue("the provider must have been queried, or this test passes for the "
+ + "wrong reason", pulls.get() >= 1);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testCredentialKindTaggedForTheOrphanDrainerTerminalPolicy() throws Exception {
+ assertMemoryLeak(() -> {
+ // The builder routes a CONSTANT credential through QwpWebSocketSender.fixedAuthHeader
+ // and an httpTokenProvider through a bare lambda. That type difference is the whole
+ // signal: hasDynamicCredential() reads it, and BackgroundDrainer.connectWithDurableAckRetry
+ // decides on it whether a 401 during an orphan drain may quarantine the slot.
+ //
+ // A mis-tag is silent at build time and asymmetric in cost. Tagging a rotating credential
+ // as fixed makes the first 401 of an orphan drain drop a .failed sentinel that nothing in
+ // production clears, permanently abandoning replayable rows over a token the next pull
+ // would have refreshed. The other direction only delays the operator's signal: a wrong
+ // fixed password rides out the attempt threshold and the dwell floor before quarantining.
+ //
+ // Nothing connected the builder half to the drainer half, so assert both on a real built
+ // sender: the header the server actually received (a tag asserted alone would still pass
+ // if the credential reached the wire by some other route) and the tag itself, read both
+ // directly and through the background reconnect factory the drainer is handed.
+ //
+ // This is the ONLY test of the classification itself: the drainer's own suites
+ // (BackgroundDrainerDurableAckRetryTest, BackgroundDrainerMidDrainAuthRejectTest) stub
+ // hasDynamicCredential() on a scripted factory, because what they pin is the terminal policy
+ // each verdict produces. Delete this test and both verdicts become assumptions.
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ assertCredentialKind(server, port, "Bearer static-token", false,
+ b -> b.httpToken("static-token"));
+ assertCredentialKind(server, port,
+ "Basic " + Base64.getEncoder().encodeToString(
+ "user:pass".getBytes(StandardCharsets.UTF_8)),
+ false,
+ b -> b.httpUsernamePassword("user", "pass"));
+ assertCredentialKind(server, port, "Bearer rotating-token", true,
+ b -> b.httpTokenProvider(() -> "rotating-token"));
+ // No credential at all: nothing to refresh, so a rejection is never
+ // a window a later pull can close.
+ assertCredentialKind(server, port, "", false, b -> {
+ });
+ }
+ });
+ }
+
+ @Test
+ public void testProviderRequeriedOnEveryReconnect() throws Exception {
+ assertMemoryLeak(() -> {
+ // The handler ACKs the first frame then drops the connection, forcing the I/O loop to reconnect.
+ // The reconnect runs the same buildAndConnect path, so it must re-query the provider and present
+ // the next token on the new upgrade - proving refresh-at-handshake, not a token captured once.
+ AtomicInteger tokenSeq = new AtomicInteger();
+ DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .httpTokenProvider(() -> "TOKEN-" + tokenSeq.incrementAndGet())
+ .build()) {
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // batch 1 lands, gets ACKed, then the server drops the socket -> reconnect
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+
+ // the reconnect handshake must carry a freshly pulled token (blocks for the reconnect)
+ Assert.assertEquals("Bearer TOKEN-2", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // batch 2 goes through on the new connection, end to end
+ sender.table("foo").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 2, 5_000);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testProviderTokenSuppliedOnInitialUpgrade() throws Exception {
+ assertMemoryLeak(() -> {
+ AtomicInteger tokenSeq = new AtomicInteger();
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .httpTokenProvider(() -> "TOKEN-" + tokenSeq.incrementAndGet())
+ .build()) {
+ // the upgrade handshake runs during build(); the provider was queried exactly once for it
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+ Assert.assertEquals(1, tokenSeq.get());
+
+ // sending data must NOT re-query the provider: the established socket carries no new auth
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ Assert.assertEquals(1, tokenSeq.get());
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testStaticTokenStillSuppliedOverWebSocket() throws Exception {
+ assertMemoryLeak(() -> {
+ // regression guard for the supplier refactor: a fixed httpToken still reaches the upgrade header
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .httpToken("static-token")
+ .build()) {
+ Assert.assertEquals("Bearer static-token", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testThrowingProviderResolvedOncePerConnectRound() throws Exception {
+ assertMemoryLeak(() -> {
+ // A token-provider failure (a failed silent refresh, or not signed in) is cluster-wide, not a
+ // per-endpoint transport fault. The credential is resolved once before the endpoint walk, so the
+ // provider is queried exactly once per connect round even across a multi-endpoint failover, and the
+ // provider's own error reaches the caller instead of being masked as "all endpoints unreachable".
+ AtomicInteger calls = new AtomicInteger();
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ // two endpoints at the same reachable server (distinct host strings, so not rejected as
+ // duplicates) - a pre-fix per-endpoint pull would query the provider twice for one connect
+ try {
+ Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .address("127.0.0.1:" + port)
+ .httpTokenProvider(() -> {
+ calls.incrementAndGet();
+ throw new OidcAuthException("no token has been obtained yet; call signIn()");
+ })
+ .build();
+ Assert.fail("expected build() to fail when the token provider throws");
+ } catch (OidcAuthException e) {
+ // the provider's own error surfaces directly, not wrapped as a transport failure
+ String msg = e.getMessage();
+ Assert.assertTrue("expected the provider's message, got: " + msg,
+ msg.contains("no token has been obtained yet"));
+ Assert.assertFalse("a provider failure must not be mislabeled as unreachable, got: " + msg,
+ msg.contains("unreachable"));
+ } catch (Exception e) {
+ Assert.fail("expected the provider's OidcAuthException to surface, got: " + e);
+ }
+ // queried once per connect round, not once per endpoint (pre-fix this would be 2)
+ Assert.assertEquals(1, calls.get());
+ }
+ });
+ }
+
+ @Test(timeout = 30_000)
+ public void testThrowingProviderFailsFastInSyncInitialConnect() throws Exception {
+ assertMemoryLeak(() -> {
+ // Setting any reconnect_* knob promotes the initial connect to SYNC mode (Sender.build). In SYNC
+ // mode a token-provider failure (not signed in / a failed refresh) must STILL fail fast with the
+ // provider's own exception - exactly like OFF mode - not be treated as a transport outage and
+ // retried for the whole reconnect budget (which would block build() for up to that budget, then
+ // surface a transport-shaped wrapper). A deterministic "no token" can never recover by retrying.
+ AtomicInteger calls = new AtomicInteger();
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ long budgetMillis = 10_000; // if the fix regressed, build() would block ~this long before failing
+ long startNanos = System.nanoTime();
+ try {
+ Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .reconnectMaxDurationMillis(budgetMillis) // -> SYNC initial connect
+ .httpTokenProvider(() -> {
+ calls.incrementAndGet();
+ throw new OidcAuthException("no token has been obtained yet; call signIn()");
+ })
+ .build();
+ Assert.fail("expected build() to fail when the token provider throws");
+ } catch (OidcAuthException e) {
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ // the provider's own error, surfaced fast - not a wrapped transport failure after the budget
+ String msg = e.getMessage();
+ Assert.assertTrue("expected the provider's message, got: " + msg,
+ msg.contains("no token has been obtained yet"));
+ Assert.assertTrue("build() must fail fast, not burn the reconnect budget; took " + elapsedMillis + "ms",
+ elapsedMillis < budgetMillis / 2);
+ } catch (Exception e) {
+ Assert.fail("SYNC-mode credential failure must surface the provider's OidcAuthException, got: " + e);
+ }
+ // one deterministic failure, not a budget's worth of retries
+ Assert.assertEquals(1, calls.get());
+ }
+ });
+ }
+
+ @Test
+ public void testThrowingProviderOnReconnectIsRetriedAndRecovers() throws Exception {
+ assertMemoryLeak(() -> {
+ // The riskiest token-provider path: a throw on the BACKGROUND I/O thread during a reconnect. The
+ // server ACKs the first frame then drops the socket, forcing a reconnect; on that reconnect the
+ // provider throws once (a transient failed silent refresh), then succeeds. connectWithRetry must
+ // catch the (non-terminal) throw and retry within the reconnect budget - re-querying the provider -
+ // so the sender recovers and batch 2 still lands, rather than the throw killing the I/O thread or
+ // being silently swallowed. A regression narrowing that catch (so the throw is not retried) fails here.
+ AtomicInteger calls = new AtomicInteger();
+ DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .httpTokenProvider(() -> {
+ int n = calls.incrementAndGet();
+ // n==1 initial connect (ok); n==2 first reconnect attempt (transient throw);
+ // n>=3 reconnect retry (ok)
+ if (n == 2) {
+ throw new OidcAuthException("transient: a silent refresh failed");
+ }
+ return "TOKEN-" + n;
+ })
+ .build()) {
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // batch 1 lands and is ACKed, then the server drops the socket -> background reconnect
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+
+ // the reconnect's first pull threw; connectWithRetry retries, re-querying the provider, and
+ // the retry's token reaches the upgrade (the throwing attempt never connected, so TOKEN-2 is
+ // never seen on the wire)
+ Assert.assertEquals("Bearer TOKEN-3", server.pollAuthorizationHeader(10, TimeUnit.SECONDS));
+
+ // batch 2 goes through on the recovered connection: the reconnect throw did not terminate it
+ sender.table("foo").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 2, 10_000);
+ Assert.assertTrue("the provider must be re-queried on the reconnect retry (>=3 pulls), got " + calls.get(),
+ calls.get() >= 3);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testCloseBreaksADrainerBlockedInACredentialPull() throws Exception {
+ assertMemoryLeak(() -> {
+ // The reconnect walk publishes the WebSocketClient it is about to block on so close() can break it
+ // (ConnectCancellation), but the credential pull that now precedes the walk is caller code owning
+ // no socket, so closeTraffic() cannot reach it. A pull can outlast close()'s 30s shutdown budget -
+ // OidcDeviceAuth.getToken() waits up to 4 x httpTimeoutMillis behind a peer's silent refresh - and
+ // during an IdP outage the drainer sits inside a pull for most of every retry cycle, so close()
+ // lands there routinely. Before the fix close() burned the whole budget and then threw
+ // "cursor I/O thread did not stop", delegating teardown, on what is a clean shutdown.
+ CountDownLatch pullEntered = new CountDownLatch(1);
+ CountDownLatch neverReleased = new CountDownLatch(1);
+ AtomicBoolean blockNextPull = new AtomicBoolean(false);
+ AtomicBoolean sawInterrupt = new AtomicBoolean(false);
+ AtomicInteger calls = new AtomicInteger();
+ DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .reconnectInitialBackoffMillis(20)
+ .reconnectMaxBackoffMillis(20)
+ .httpTokenProvider(() -> {
+ int n = calls.incrementAndGet();
+ if (blockNextPull.get()) {
+ pullEntered.countDown();
+ try {
+ // only an interrupt can free this, exactly like OidcDeviceAuth's timed
+ // wait for its instance lock behind a peer's silent refresh
+ neverReleased.await(45, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ sawInterrupt.set(true);
+ throw new OidcAuthException("interrupted while waiting for a token");
+ }
+ }
+ return "TOKEN-" + n;
+ })
+ .build();
+ boolean closed = false;
+ try {
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // arm the block, then let the server's drop drive the background reconnect into the pull
+ blockNextPull.set(true);
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ Assert.assertTrue("the drainer must reach the credential pull",
+ pullEntered.await(15, TimeUnit.SECONDS));
+
+ long startNanos = System.nanoTime();
+ sender.close();
+ closed = true;
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+ // the budget is 30s; anything near it means close() waited it out instead of breaking
+ // the pull. A generous ceiling keeps this off the CI flake line while still failing the
+ // pre-fix behaviour by a wide margin.
+ Assert.assertTrue("close() must break the pull, not wait out the shutdown budget; took "
+ + elapsedMillis + "ms", elapsedMillis < 15_000);
+ Assert.assertTrue("close() must interrupt the thread parked in the pull", sawInterrupt.get());
+ } finally {
+ if (!closed) {
+ neverReleased.countDown();
+ sender.close();
+ }
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testPersistentCredentialOutageIsReportedToTheErrorHandler() throws Exception {
+ assertMemoryLeak(() -> {
+ // Retrying a credential outage forever (Invariant B) must not make it programmatically INVISIBLE.
+ // A revoked refresh token or a permanently dead IdP is not self-healing, yet the drainer keeps
+ // retrying and flush() keeps returning success while SF absorbs the rows; without a dispatched
+ // SenderError the only signal is a throttled slf4j WARN - and this library ships embedded, often
+ // with no binding configured - until SF fills and the failure resurfaces as ring backpressure,
+ // pointing the operator at disk sizing instead of at their credentials. The auth/upgrade and
+ // durable-ack policy failures already dispatch a RETRIABLE error for exactly this reason; the
+ // credential arm did not. RETRIABLE, not TERMINAL: the handler learns the wire is down while the
+ // producer stays alive and no data is at risk.
+ AtomicBoolean providerFailing = new AtomicBoolean(false);
+ AtomicInteger calls = new AtomicInteger();
+ AtomicReference credentialError = new AtomicReference<>();
+ AtomicReference terminalError = new AtomicReference<>();
+ DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .reconnectInitialBackoffMillis(20)
+ .reconnectMaxBackoffMillis(20)
+ .errorHandler(e -> {
+ if (e.getAppliedPolicy() == SenderError.Policy.TERMINAL) {
+ terminalError.compareAndSet(null, e);
+ } else if (e.getServerMessage() != null
+ && e.getServerMessage().contains("credential-unavailable")) {
+ credentialError.compareAndSet(null, e);
+ }
+ })
+ .httpTokenProvider(() -> {
+ int n = calls.incrementAndGet();
+ if (providerFailing.get()) {
+ throw new OidcAuthException("persistent: not signed in");
+ }
+ return "TOKEN-" + n;
+ })
+ .build()) {
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // arm the outage before the drop, so every reconnect pull throws
+ providerFailing.set(true);
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 1, 5_000);
+
+ // the handler must be told, by category and by message, that the CREDENTIAL is the problem
+ waitFor(() -> credentialError.get() != null, 15_000);
+ SenderError err = credentialError.get();
+ Assert.assertEquals(SenderError.Category.SECURITY_ERROR, err.getCategory());
+ Assert.assertEquals(SenderError.Policy.RETRIABLE, err.getAppliedPolicy());
+ Assert.assertTrue("the provider's own message must reach the handler: " + err.getServerMessage(),
+ err.getServerMessage().contains("not signed in"));
+
+ // and it stays RETRIABLE: no terminal, and the producer is still alive
+ Assert.assertNull("a credential outage must never latch a terminal", terminalError.get());
+ sender.table("foo").longColumn("v", 2L).atNow();
+
+ // the provider recovers -> the next reconnect succeeds and the buffered rows drain
+ providerFailing.set(false);
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 2, 15_000);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testPersistentlyThrowingProviderOnReconnectDoesNotTerminateAndRecovers() throws Exception {
+ assertMemoryLeak(() -> {
+ // Invariant B: the RUNNING store-and-forward drainer must NEVER terminate on a token-provider
+ // failure, however long it persists. A failing provider (IdP unreachable, a silent refresh failing,
+ // sign-in not yet complete) is a transient outage like any other - the un-acked rows stay safe in SF
+ // and the sender recovers once a token is available again. Here the provider throws for FAR longer
+ // than the (deliberately short) reconnect budget: the sender must stay alive the whole time - no
+ // terminal, no exception surfaced to the producer - and then ship the buffered row once the provider
+ // recovers. Before the fix the drainer latched a TERMINAL SECURITY_ERROR at reconnectMaxDurationMillis
+ // and dropped the producer store-and-forward had promised to keep alive.
+ AtomicBoolean providerFailing = new AtomicBoolean(false);
+ AtomicInteger calls = new AtomicInteger();
+ DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ long budgetMillis = 300; // SHORT: the outage below far exceeds it, proving the budget is not consulted
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .reconnectInitialBackoffMillis(20)
+ .reconnectMaxBackoffMillis(20)
+ .reconnectMaxDurationMillis(budgetMillis)
+ .httpTokenProvider(() -> {
+ int n = calls.incrementAndGet();
+ if (providerFailing.get()) {
+ throw new OidcAuthException("persistent: not signed in");
+ }
+ return "TOKEN-" + n;
+ })
+ .build()) {
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // Arm the provider failure on the established connection, BEFORE the drop triggers a reconnect,
+ // so every reconnect pull throws (no race where the first reconnect succeeds first).
+ providerFailing.set(true);
+ // batch 1 lands and is ACKed on the initial connection, then the server drops the socket ->
+ // the background reconnect loop starts and every provider pull now throws
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 1, 5_000);
+
+ // let the failing reconnect run for 4x the budget - the old code would have terminated at 1x
+ int callsAtOutageStart = calls.get();
+ Thread.sleep(budgetMillis * 4);
+
+ // the drainer kept re-querying the provider (retrying, not giving up) ...
+ Assert.assertTrue("the provider must be re-queried during the outage, got " + calls.get(),
+ calls.get() > callsAtOutageStart);
+ // ... and the sender is still ALIVE: buffering another row must not surface a terminal even
+ // though every reconnect is currently failing. Before the fix this threw a SECURITY_ERROR
+ // "token-provider-failed" once the budget elapsed.
+ try {
+ sender.table("foo").longColumn("v", 2L).atNow();
+ } catch (Exception e) {
+ Assert.fail("the drainer terminated the sender on a transient provider outage: " + e.getMessage());
+ }
+
+ // the provider recovers -> the next reconnect succeeds and the buffered row drains
+ providerFailing.set(false);
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 2, 15_000);
+ }
+ }
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testCredentialFailuresInterleavedWithRoleRejectsDoNotTerminateAndRecover() throws Exception {
+ assertMemoryLeak(() -> {
+ // Neither a token-provider failure nor a transient role reject may terminate the running drainer, and
+ // interleaving them must not either: both fall through to capped backoff and retry indefinitely
+ // (Invariant B). Here credential blips (even provider calls throw) alternate with 421 role rejects
+ // (odd calls return a token whose upgrade the server rejects) for far longer than the reconnect
+ // budget; the sender must survive the whole span and then ship batch 2 once both faults clear. Before
+ // the fix the credential blips accumulated to a budget-latched terminal and the sender was dropped.
+ AtomicInteger calls = new AtomicInteger();
+ DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ long budgetMillis = 300; // short: the interleaved outage below far exceeds it
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .reconnectInitialBackoffMillis(20)
+ .reconnectMaxBackoffMillis(20)
+ .reconnectMaxDurationMillis(budgetMillis)
+ .httpTokenProvider(() -> {
+ int n = calls.incrementAndGet();
+ // call 1: the initial connect (must succeed). Then alternate on every reconnect
+ // attempt: even calls THROW (a credential blip), odd calls RETURN a token whose
+ // connect then hits the 421 role reject below. So credential failures and role
+ // rejects strictly alternate across the whole outage.
+ if (n > 1 && n % 2 == 0) {
+ throw new OidcAuthException("transient: a silent refresh failed");
+ }
+ return "TOKEN-" + n;
+ })
+ .build()) {
+ Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+
+ // reject every NEW handshake with a transient 421 role reject BEFORE the drop, so a
+ // token-returning reconnect attempt deterministically hits it. The already-established
+ // initial connection is unaffected and still ships batch 1.
+ server.setRejectWithRole("replica");
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 1, 5_000);
+
+ // let the interleaved credential + role failures run for well over the budget; the sender
+ // must NOT terminate on either fault class or their interleaving
+ Thread.sleep(budgetMillis * 4);
+ try {
+ sender.table("foo").longColumn("v", 2L).atNow();
+ } catch (Exception e) {
+ Assert.fail("the drainer terminated during interleaved credential/role failures: " + e.getMessage());
+ }
+
+ // clear the reject: the next token-returning reconnect now succeeds and batch 2 drains
+ server.setRejectWithRole(null);
+ sender.flush();
+ waitFor(() -> handler.totalBinaryReceived.get() >= 2, 15_000);
+ Assert.assertTrue("the provider must have been re-queried across the reconnect phase, got " + calls.get(),
+ calls.get() >= 4);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testUsernamePasswordStillSuppliedOverWebSocket() throws Exception {
+ assertMemoryLeak(() -> {
+ // regression guard for the supplier refactor: username/password still becomes the Basic header
+ try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port)
+ .httpUsernamePassword("user", "pass")
+ .build()) {
+ String expected = "Basic " + Base64.getEncoder().encodeToString(
+ "user:pass".getBytes(StandardCharsets.UTF_8));
+ Assert.assertEquals(expected, server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ }
+ }
+ });
+ }
+
+ // Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16
+ private static byte[] buildAck(long seq) {
+ byte[] buf = new byte[1 + 8 + 2];
+ ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x00); // STATUS_OK
+ bb.putLong(seq);
+ bb.putShort((short) 0);
+ return buf;
+ }
+
+ private static void waitFor(BoolCondition cond, long timeoutMillis) {
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ if (cond.test()) return;
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ Assert.fail("interrupted");
+ }
+ }
+ Assert.fail("waitFor timed out after " + timeoutMillis + "ms");
+ }
+
+ @FunctionalInterface
+ private interface BoolCondition {
+ boolean test();
+ }
+
+ /** ACKs every binary frame so the sender doesn't hang. */
+ private static class AckHandler implements TestWebSocketServer.WebSocketServerHandler {
+ private final AtomicLong nextSeq = new AtomicLong(0);
+
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ try {
+ client.sendBinary(buildAck(nextSeq.getAndIncrement()));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * ACKs every binary frame; on the first connection's first frame it closes the socket right after
+ * the ACK, so the sender's I/O loop must reconnect to deliver the next batch. Later connections ACK
+ * normally.
+ */
+ private static class DropAfterFirstAckHandler implements TestWebSocketServer.WebSocketServerHandler {
+ final AtomicInteger connectionsAccepted = new AtomicInteger();
+ final AtomicLong totalBinaryReceived = new AtomicLong();
+ private final AtomicLong nextSeq = new AtomicLong(0);
+ private TestWebSocketServer.ClientHandler firstClient;
+
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ if (firstClient == null || firstClient != client) {
+ connectionsAccepted.incrementAndGet();
+ if (firstClient == null) {
+ firstClient = client;
+ }
+ }
+ totalBinaryReceived.incrementAndGet();
+ try {
+ client.sendBinary(buildAck(nextSeq.getAndIncrement()));
+ if (totalBinaryReceived.get() == 1) {
+ // brief sleep so the queued ACK flushes before we close the socket under it
+ Thread.sleep(50);
+ client.close();
+ }
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private static void assertCredentialKind(
+ TestWebSocketServer server,
+ int port,
+ String expectedHeader,
+ boolean expectedDynamic,
+ Consumer credential
+ ) throws Exception {
+ Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET)
+ .address("localhost:" + port);
+ credential.accept(builder);
+ try (Sender sender = builder.build()) {
+ Assert.assertEquals("the configured credential must reach the upgrade header",
+ expectedHeader, server.pollAuthorizationHeader(5, TimeUnit.SECONDS));
+ QwpWebSocketSender qwp = (QwpWebSocketSender) sender;
+ Assert.assertEquals("credential tag for [" + expectedHeader + "]",
+ expectedDynamic, qwp.isCredentialDynamic());
+ // The value BackgroundDrainer.connectWithDurableAckRetry actually reads:
+ // ReconnectFactory.hasDynamicCredential() on the background factory an
+ // orphan drainer is handed.
+ Assert.assertEquals("drainer-visible credential tag for [" + expectedHeader + "]",
+ expectedDynamic,
+ qwp.newBackgroundReconnectFactory(() -> false).hasDynamicCredential());
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java
new file mode 100644
index 000000000..da7b7ca31
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java
@@ -0,0 +1,414 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
+import io.questdb.client.cutlass.qwp.client.QwpCredentialUnavailableException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.std.Files;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Credential-outage observability for an orphan {@link BackgroundDrainer}.
+ *
+ * A credential the client cannot ACQUIRE — the configured token provider throws
+ * instead of returning one, after a revocation, an IdP outage, or a sign-in the
+ * user has not finished — is retried indefinitely under Invariant B, exactly like
+ * a transport outage: the un-acked rows stay safe in store-and-forward and no
+ * {@code .failed} sentinel is dropped. Retrying forever is correct; retrying
+ * SILENTLY is not. A revoked refresh token does not heal on its own, so with no
+ * report the outage is invisible until SF fills and resurfaces as ring
+ * backpressure, which points the operator at disk sizing instead of at their
+ * credentials.
+ *
+ * The foreground sender already reports it (see
+ * {@code WebSocketTokenProviderTest#testPersistentCredentialOutageIsReportedToTheErrorHandler}).
+ * An orphan drainer rides out the very same fault and had neither half:
+ *
+ * - its drain loop's {@code SenderError} dispatcher was never wired, so the
+ * loop's own {@code credential-unavailable} report was dropped into a null;
+ * - at initial connect the exception matched none of the typed arms and landed
+ * in the generic transport arm, whose WARN says "cluster unreachable" — the
+ * wrong condition, sending the operator after a network fault that does not
+ * exist.
+ *
+ * Both halves are pinned here.
+ *
+ * Wire realism matches {@link BackgroundDrainerMidDrainAuthRejectTest}: a real
+ * {@link TestWebSocketServer} durably acks over a live socket while the scripted
+ * {@link CursorWebSocketSendLoop.ReconnectFactory} decides, per connect attempt,
+ * whether the sweep produces a client or fails to obtain a credential.
+ */
+public class BackgroundDrainerCredentialOutageReportTest {
+
+ private static final long FAST_BACKOFF_MAX_MILLIS = 4L;
+ private static final long FAST_BACKOFF_MILLIS = 1L;
+ private static final String PROVIDER_FAILURE_MESSAGE = "refresh token revoked by the IdP";
+ private static final long RECONNECT_MAX_DURATION_MILLIS = 25L;
+ private static final int SEEDED_FRAMES = 5;
+ private static final long SEGMENT_SIZE_BYTES = 16_384L;
+ private static final long SF_MAX_TOTAL_BYTES = 1L << 20;
+ private static final String TABLE = "trades";
+
+ private String slotPath;
+
+ // one shared temp-directory mechanism instead of a per-class java.io.tmpdir path plus a hand-rolled
+ // recursive delete: the rule cleans up on failure and on an exception thrown out of a test too
+ @Rule
+ public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+ @Before
+ public void setUp() {
+ slotPath = temp.getRoot().toPath().resolve("slot").toString();
+ assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT));
+ }
+
+
+ @Test
+ public void testInitialConnectCredentialOutageIsNamedNotMislabelledUnreachable() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // The drain loop does not exist yet at initial connect, so the sink cannot
+ // carry this one -- the log is the only diagnostic, which makes naming the
+ // condition the whole of the fix. "cluster unreachable" is actively
+ // misleading here: nothing was attempted on the wire at all.
+ seedSlot(SEEDED_FRAMES);
+ AckAllHandler handler = new AckAllHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ // Calls 1-2: the token provider throws. Call 3+: it hands over a token.
+ ScriptedWireFactory factory = new ScriptedWireFactory(server.getPort(), 1, 2);
+ BackgroundDrainer drainer = newDrainer(factory);
+
+ ch.qos.logback.classic.Logger drainerLogger = (ch.qos.logback.classic.Logger)
+ org.slf4j.LoggerFactory.getLogger(BackgroundDrainer.class);
+ ch.qos.logback.core.read.ListAppender appender =
+ new ch.qos.logback.core.read.ListAppender<>();
+ appender.start();
+ ch.qos.logback.classic.Level savedLevel = drainerLogger.getLevel();
+ drainerLogger.setLevel(ch.qos.logback.classic.Level.ALL);
+ drainerLogger.addAppender(appender);
+ try {
+ runToCompletion(drainer);
+ } finally {
+ drainerLogger.detachAppender(appender);
+ drainerLogger.setLevel(savedLevel);
+ appender.stop();
+ }
+
+ // Invariant B: a credential outage is transient, so it is ridden out --
+ // never quarantined, and the drain completes once a token appears.
+ assertEquals(BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome());
+ assertFalse("a credential outage must never quarantine the slot",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ assertTrue("the drainer must have ridden out both outage sweeps, attempts="
+ + factory.attempts(), factory.attempts() >= 3);
+
+ boolean named = false;
+ boolean mislabelled = false;
+ for (ch.qos.logback.classic.spi.ILoggingEvent e : appender.list) {
+ String msg = e.getFormattedMessage();
+ if (msg.contains("token provider failed to supply a credential")
+ && msg.contains(PROVIDER_FAILURE_MESSAGE)) {
+ named = true;
+ }
+ if (msg.contains("cluster unreachable")) {
+ mislabelled = true;
+ }
+ }
+ assertTrue("a credential outage at initial connect must name itself in the log -- "
+ + "it is the only diagnostic that path produces. Saw: " + appender.list, named);
+ assertFalse("a credential outage must not be reported as a network fault. Saw: "
+ + appender.list, mislabelled);
+ }
+ });
+ }
+
+ @Test
+ public void testMidDrainCredentialOutageReachesTheErrorSink() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // The wire drops after one durable ack; the loop's own reconnect sweeps then
+ // fail to obtain a credential. The loop rides that out itself (it never
+ // reaches the drainer's connect path), so its dispatcher is the ONLY route
+ // to the sink -- and it was never wired on an orphan drainer.
+ seedSlot(SEEDED_FRAMES);
+ DropFirstHandler handler = new DropFirstHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ // Call 1: healthy connect, drain starts. Calls 2-4: the mid-drain
+ // reconnect cannot obtain a credential. Call 5+: a token is available
+ // again and the drain finishes.
+ ScriptedWireFactory factory = new ScriptedWireFactory(server.getPort(), 2, 4);
+ BackgroundDrainer drainer = newDrainer(factory);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ runToCompletion(drainer);
+
+ assertEquals("a credential outage must be ridden out, not quarantined",
+ BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome());
+ assertFalse("no .failed sentinel after a drain that recovered",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+
+ SenderError credentialError = null;
+ for (SenderError e : captured) {
+ if (e.getServerMessage() != null
+ && e.getServerMessage().contains("credential-unavailable")) {
+ credentialError = e;
+ break;
+ }
+ }
+ assertTrue("the credential outage must reach the drainer's error sink -- without it "
+ + "the only signal is a throttled slf4j WARN, a NOP in an app with no "
+ + "binding configured. Saw: " + captured,
+ credentialError != null);
+ assertEquals(SenderError.Category.SECURITY_ERROR, credentialError.getCategory());
+ // RETRIABLE, not TERMINAL: the rows are safe in SF and the drain recovers.
+ assertEquals(SenderError.Policy.RETRIABLE, credentialError.getAppliedPolicy());
+ assertTrue("the provider's own failure must be carried through: "
+ + credentialError.getServerMessage(),
+ credentialError.getServerMessage().contains(PROVIDER_FAILURE_MESSAGE));
+
+ for (SenderError e : captured) {
+ assertFalse("a drain that recovered must report no data loss: " + captured,
+ e.getCategory() == SenderError.Category.DATA_LOSS);
+ // An ORPHAN loop latches TERMINAL only to hand the slot back to this
+ // drainer, which then decides. Forwarding it would announce a dead
+ // producer for a fault the very next sweep clears.
+ assertFalse("the loop's hand-back terminal must not reach the sink: " + captured,
+ e.getAppliedPolicy() == SenderError.Policy.TERMINAL);
+ }
+ }
+ });
+ }
+
+ private static byte[] durableAckFrame(long seqTxn) {
+ byte[] name = TABLE.getBytes(StandardCharsets.UTF_8);
+ ByteBuffer bb = ByteBuffer.allocate(1 + 2 + 2 + name.length + 8)
+ .order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x02); // STATUS_DURABLE_ACK
+ bb.putShort((short) 1); // tableCount
+ bb.putShort((short) name.length);
+ bb.put(name);
+ bb.putLong(seqTxn);
+ return bb.array();
+ }
+
+ private static byte[] okFrame(long wireSeq, long seqTxn) {
+ byte[] name = TABLE.getBytes(StandardCharsets.UTF_8);
+ ByteBuffer bb = ByteBuffer.allocate(1 + 8 + 2 + 2 + name.length + 8)
+ .order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x00); // STATUS_OK
+ bb.putLong(wireSeq);
+ bb.putShort((short) 1); // tableCount
+ bb.putShort((short) name.length);
+ bb.put(name);
+ bb.putLong(seqTxn);
+ return bb.array();
+ }
+
+ private BackgroundDrainer newDrainer(ScriptedWireFactory factory) {
+ return new BackgroundDrainer(
+ slotPath,
+ SEGMENT_SIZE_BYTES,
+ SF_MAX_TOTAL_BYTES,
+ factory,
+ RECONNECT_MAX_DURATION_MILLIS,
+ FAST_BACKOFF_MILLIS,
+ FAST_BACKOFF_MAX_MILLIS,
+ /* requestDurableAck */ true,
+ /* durableAckKeepaliveIntervalMillis */ 200L);
+ }
+
+
+ private static void runToCompletion(BackgroundDrainer drainer) throws InterruptedException {
+ Thread t = new Thread(drainer, "test-credential-outage-drainer");
+ t.setDaemon(true);
+ t.start();
+ t.join(20_000);
+ if (t.isAlive()) {
+ drainer.requestStop();
+ t.join(5_000);
+ fail("drainer did not finish within 20s (outcome=" + drainer.outcome() + ")");
+ }
+ }
+
+ private void seedSlot(int frames) {
+ try (CursorSendEngine engine = new CursorSendEngine(slotPath, SEGMENT_SIZE_BYTES)) {
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ byte[] payload = "frame-bytes-padd".getBytes(StandardCharsets.US_ASCII);
+ for (int i = 0; i < payload.length; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, payload[i]);
+ }
+ for (int i = 0; i < frames; i++) {
+ engine.appendBlocking(buf, 16);
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+ }
+
+ /**
+ * Durably acks everything on every connection — the wire is never the fault
+ * under test here, only the credential the client cannot obtain to open it.
+ */
+ private static final class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler {
+ private final java.util.Map wireSeqByConn =
+ new java.util.IdentityHashMap<>();
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ long[] counter = wireSeqByConn.computeIfAbsent(client, k -> new long[1]);
+ long seq = counter[0]++;
+ try {
+ client.sendBinary(okFrame(seq, seq));
+ client.sendBinary(durableAckFrame(seq));
+ } catch (IOException ignored) {
+ // Best-effort ack: the connection died under us; the client replays.
+ }
+ }
+ }
+
+ /**
+ * Server-side script. Connection #1 durably acks exactly one frame, then closes
+ * the socket — a deterministic mid-drain wire drop that forces the loop's own
+ * reconnect sweep. Every later connection acks all traffic, so a reconnected
+ * loop drains to completion.
+ */
+ private static final class DropFirstHandler implements TestWebSocketServer.WebSocketServerHandler {
+ private final List arrivalOrder = new ArrayList<>();
+ private final java.util.Map wireSeqByConn =
+ new java.util.IdentityHashMap<>();
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ long[] counter = wireSeqByConn.get(client);
+ if (counter == null) {
+ counter = new long[1];
+ wireSeqByConn.put(client, counter);
+ arrivalOrder.add(client);
+ }
+ int connectionIndex = arrivalOrder.indexOf(client) + 1;
+ long seq = counter[0]++;
+ try {
+ if (connectionIndex == 1) {
+ if (seq == 0) {
+ client.sendBinary(okFrame(seq, seq));
+ client.sendBinary(durableAckFrame(seq));
+ } else if (seq == 1) {
+ client.close(); // mid-drain wire drop
+ }
+ // seq > 1: late buffered frames from the condemned connection; ignore.
+ } else {
+ client.sendBinary(okFrame(seq, seq));
+ client.sendBinary(durableAckFrame(seq));
+ }
+ } catch (IOException ignored) {
+ // Best-effort ack: the connection died under us; the client replays.
+ }
+ }
+ }
+
+ /**
+ * Per-call-index scripted factory over a real wire. Call indexes inside
+ * {@code [throwFrom, throwTo]} (1-based, inclusive) fail to obtain a credential —
+ * a {@link QwpCredentialUnavailableException} wrapping the provider's own
+ * exception, exactly as {@code QwpWebSocketSender} wraps a throwing
+ * {@code httpTokenProvider}. Every other call returns a live upgraded client.
+ */
+ private static final class ScriptedWireFactory implements CursorWebSocketSendLoop.ReconnectFactory {
+ private final AtomicInteger calls = new AtomicInteger();
+ private final int port;
+ private final int throwFrom;
+ private final int throwTo;
+
+ ScriptedWireFactory(int port, int throwFrom, int throwTo) {
+ this.port = port;
+ this.throwFrom = throwFrom;
+ this.throwTo = throwTo;
+ }
+
+ int attempts() {
+ return calls.get();
+ }
+
+ @Override
+ public boolean hasDynamicCredential() {
+ // A token provider is by definition a rotating credential.
+ return true;
+ }
+
+ @Override
+ public WebSocketClient reconnect() throws Exception {
+ int n = calls.incrementAndGet();
+ if (n >= throwFrom && n <= throwTo) {
+ throw new QwpCredentialUnavailableException(
+ new RuntimeException(PROVIDER_FAILURE_MESSAGE));
+ }
+ WebSocketClient c = WebSocketClientFactory.newPlainTextInstance();
+ try {
+ c.setQwpMaxVersion(1);
+ c.setQwpRequestDurableAck(true);
+ c.setConnectTimeout(5_000);
+ c.connect("localhost", port);
+ c.upgrade("/write/v4", 5_000, null);
+ } catch (Throwable t) {
+ c.close();
+ throw t;
+ }
+ return c;
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java
index 4dde3f9e0..7f983ca89 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java
@@ -30,6 +30,8 @@
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
import io.questdb.client.network.PlainSocketFactory;
import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
+import io.questdb.client.std.Os;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
@@ -41,9 +43,10 @@
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
-import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -54,6 +57,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
@@ -90,10 +94,14 @@ public class BackgroundDrainerDurableAckRetryTest {
private String slotPath;
+ // one shared temp-directory mechanism instead of a per-class java.io.tmpdir path plus a hand-rolled
+ // recursive delete: the rule cleans up on failure and on an exception thrown out of a test too
+ @Rule
+ public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
@Before
public void setUp() {
- slotPath = Paths.get(System.getProperty("java.io.tmpdir"),
- "qdb-da-retry-" + System.nanoTime()).toString();
+ slotPath = temp.getRoot().toPath().resolve("slot").toString();
assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT));
}
@@ -101,28 +109,12 @@ public void setUp() {
public void tearDown() {
// Safety net for exits that bypass the assertMemoryLeak wrapper;
// normally a no-op because the wrapper's finally already closed
- // and cleared the stubs (close() is idempotent).
+ // and cleared the stubs (close() is idempotent). The slot directory
+ // itself is the TemporaryFolder rule's job.
closeAllStubs();
- if (slotPath == null) return;
- long find = Files.findFirst(slotPath);
- if (find > 0) {
- try {
- int rc = 1;
- while (rc > 0) {
- String name = Files.utf8ToString(Files.findName(find));
- if (name != null && !".".equals(name) && !"..".equals(name)) {
- Files.remove(slotPath + "/" + name);
- }
- rc = Files.findNext(find);
- }
- } finally {
- Files.findClose(find);
- }
- }
- Files.remove(slotPath);
}
- @Test
+ @Test(timeout = 60_000)
public void testCallbackArgumentsCarrySlotPathAndAttemptNumber() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -141,7 +133,7 @@ public void testCallbackArgumentsCarrySlotPathAndAttemptNumber() throws Exceptio
});
}
- @Test
+ @Test(timeout = 60_000)
public void testEscalatesAfterMaxAttemptsAndDropsSentinel() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -167,7 +159,7 @@ public void testEscalatesAfterMaxAttemptsAndDropsSentinel() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testListenerThrowingOnPersistentFailureStillMarksFailed() throws Exception {
assertMemoryLeak(() -> {
BackgroundDrainerListener throwing = new BackgroundDrainerListener() {
@@ -193,7 +185,7 @@ public void onDurableAckUnavailable(String slotPath, int attemptNumber) {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testListenerThrowingOnUnavailableContinuesRetrying() throws Exception {
assertMemoryLeak(() -> {
AtomicInteger unavailableCalls = new AtomicInteger();
@@ -222,7 +214,7 @@ public void onDurableAckUnavailable(String slotPath, int attemptNumber) {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testNoListenerNoNullPointerOnEscalation() throws Exception {
assertMemoryLeak(() -> {
ScriptedFactory factory = ScriptedFactory.alwaysFailing(
@@ -236,7 +228,7 @@ public void testNoListenerNoNullPointerOnEscalation() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testTerminalUpgradeMarksFailedImmediately() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -274,7 +266,430 @@ public void testTerminalUpgradeMarksFailedImmediately() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
+ public void testFixedCredentialAuthRejectionStillQuarantinesImmediately() throws Exception {
+ assertMemoryLeak(() -> {
+ // A 401 against a CONSTANT credential is a permanent misconfiguration: re-presenting the same
+ // header cannot change the answer, so the pre-existing fail-fast quarantine must stay exactly as
+ // it was. This is the control for the rotating-credential case below - the settle budget must
+ // key off the credential's nature, not relax auth handling across the board.
+ ScriptedFactory factory = ScriptedFactory.alwaysFailing(
+ () -> new QwpAuthFailedException(401, "127.0.0.1", 9000));
+ BackgroundDrainer drainer = newDrainer(factory);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ WebSocketClient out = drainer.connectWithDurableAckRetry();
+
+ assertNull(out);
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertEquals("a constant credential must not be retried", 1, factory.attempts());
+ assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ assertEquals("exactly one abandonment report: " + captured, 1, captured.size());
+ assertEquals(SenderError.Category.DATA_LOSS, captured.get(0).getCategory());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted() throws Exception {
+ assertMemoryLeak(() -> {
+ // The settle budget must be BOUNDED, not "retry forever". Quarantine is permitted only once both
+ // the attempt threshold and the wall-clock dwell floor are exhausted; this short test budget pins
+ // the terminal behavior without waiting for the production five-minute default.
+ ScriptedFactory factory = ScriptedFactory
+ .alwaysFailing(() -> new QwpAuthFailedException(401, "127.0.0.1", 9000))
+ .withDynamicCredential();
+ // Far above what the six attempts cost on their own (six capped backoffs of 1-4ms, ~15ms
+ // total): at the 25ms this used to use, the assertion below cleared the floor with ~10ms of
+ // margin, so a machine that merely ran the attempts slowly satisfied it without the dwell being
+ // honoured at all. A quarter second is still a quarter second of test time, and an elapsed of
+ // ~15ms against it is unmistakable.
+ long authDwellFloorMillis = 250L;
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ factory, authDwellFloorMillis, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ long startNanos = System.nanoTime();
+ WebSocketClient out = drainer.connectWithDurableAckRetry();
+ long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
+
+ assertNull(out);
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertTrue("the attempt threshold must be reached",
+ factory.attempts() >= BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS);
+ assertTrue("quarantine must not precede the auth dwell floor [elapsedMillis="
+ + elapsedMillis + "]",
+ elapsedMillis >= authDwellFloorMillis);
+ assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ assertEquals("exactly one abandonment report: " + captured, 1, captured.size());
+ assertEquals(SenderError.Category.DATA_LOSS, captured.get(0).getCategory());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testRotatingCredentialAuthRejectionRidesPastAttemptThresholdBeforeDwellFloor() throws Exception {
+ assertMemoryLeak(() -> {
+ // Six fast 401s must not strand the slot while the configured self-healing window is still open.
+ // The seventh attempt succeeds, proving that attempt count alone cannot quarantine replayable data.
+ ScriptedFactory factory = ScriptedFactory
+ .failingTimes(BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS,
+ () -> new QwpAuthFailedException(401, "127.0.0.1", 9000))
+ .withDynamicCredential();
+ BackgroundDrainer drainer = newDrainer(factory);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ WebSocketClient out = drainer.connectWithDurableAckRetry();
+
+ assertSame("the drainer must keep trying after the fast attempt threshold",
+ factory.successSentinel(), out);
+ assertEquals(BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS + 1,
+ factory.attempts());
+ assertNotEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertFalse("a recovered credential must leave no .failed sentinel",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ assertTrue("no abandonment may be reported: " + captured, captured.isEmpty());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testRotatingCredentialAuthRejectionRidesOutBoundedBudget() throws Exception {
+ assertMemoryLeak(() -> {
+ // With a ROTATING credential the Authorization header is re-derived from the token provider on
+ // every sweep, so a 401 can be a window that heals itself: a revocation landing mid-flight, an
+ // identity provider rotating signing keys, a token expiring during the settle so the next pull
+ // refreshes it. Quarantining on the first one permanently abandons replayable data - nothing in
+ // production clears the .failed sentinel - on a fault that repairs itself in seconds.
+ ScriptedFactory factory = ScriptedFactory
+ .failingTimes(2, () -> new QwpAuthFailedException(401, "127.0.0.1", 9000))
+ .withDynamicCredential();
+ BackgroundDrainer drainer = newDrainer(factory);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ WebSocketClient out = drainer.connectWithDurableAckRetry();
+
+ assertSame("the drainer must recover once the credential is accepted",
+ factory.successSentinel(), out);
+ assertEquals(3, factory.attempts());
+ assertNotEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertFalse("a recovered credential must leave no .failed sentinel",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ assertTrue("no abandonment may be reported: " + captured, captured.isEmpty());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testRotatingCredentialAuthDwellIsClampedSoEscalationStaysReachable() {
+ // reconnect_max_duration_millis is validated only as > 0, and Long.MAX_VALUE is the documented way
+ // to ask a reconnect never to give up. TimeUnit saturates it, so the dwell half of the rotating-401
+ // gate - an AND, unlike the capability-gap gate's OR - became unsatisfiable and the ride-out never
+ // ended: the drainer swept forever, never wrote the .failed sentinel, never reported DATA_LOSS, and
+ // pinned the slot lock plus one worker of a FIXED-size drainer pool for the life of the process.
+ //
+ // This pins the clamp as a FUNCTION. On its own that proves nothing about the connect loop, which
+ // could go on using the raw budget - so testConnectLoopAppliesTheClampedRotating401Dwell drives the
+ // loop itself against a saturated reconnect_max_duration_millis, pre-ageing the rejection anchor past
+ // the ceiling rather than waiting out five minutes of wall clock. Keep the two together: neither
+ // half is worth much alone. The third leg - that a FINITE dwell does quarantine - is
+ // testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted, with a 250ms budget.
+ long ceilingNanos = TimeUnit.MILLISECONDS.toNanos(
+ BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS);
+
+ assertEquals("a saturated budget must not saturate the dwell",
+ ceilingNanos, BackgroundDrainer.dynamicCredentialAuthDwellNanos(Long.MAX_VALUE));
+ assertTrue("and the clamped dwell must be reachable at all",
+ BackgroundDrainer.dynamicCredentialAuthDwellNanos(Long.MAX_VALUE) < Long.MAX_VALUE);
+ assertEquals("a budget above the ceiling is clamped to it", ceilingNanos,
+ BackgroundDrainer.dynamicCredentialAuthDwellNanos(
+ BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS * 10));
+ // a smaller configured budget is honoured as-is, so tuning down still works - and this is the value
+ // the existing end-to-end quarantine tests rely on
+ assertEquals("a budget below the ceiling is used as configured",
+ TimeUnit.MILLISECONDS.toNanos(25L), BackgroundDrainer.dynamicCredentialAuthDwellNanos(25L));
+ assertEquals("including the default, which IS the ceiling", ceilingNanos,
+ BackgroundDrainer.dynamicCredentialAuthDwellNanos(
+ CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS));
+ }
+
+ @Test(timeout = 60_000)
+ public void testConnectLoopAppliesTheClampedRotating401Dwell() throws Exception {
+ assertMemoryLeak(() -> {
+ // The clamp asserted above is a pure function; this asserts the CALL SITE applies it. Nothing
+ // else does: every other end-to-end test here configures a dwell far below the ceiling, where
+ // Math.min returns its first argument either way, so the connect loop reverting to the raw
+ // TimeUnit.MILLISECONDS.toNanos(reconnectMaxDurationMillis) leaves all of them green - and
+ // reconnect_max_duration_millis is validated only as > 0, with Long.MAX_VALUE the documented way
+ // to ask a reconnect never to give up. Saturated, the dwell conjunct can never be satisfied, so
+ // the ride-out never ends: no .failed sentinel, no DATA_LOSS report, and the slot lock plus one
+ // worker of a FIXED-size drainer pool pinned for the life of the process.
+ //
+ // Reaching the ceiling honestly costs five minutes of wall clock, so the rejection anchor is
+ // pre-aged past it instead and the loop runs for real against it.
+ final long anchorAgeMillis = BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS * 2;
+ // Bounds the counterfactual: unclamped, the loop never escalates, and without this it would run
+ // to the test timeout with nothing to say. Stopping it turns that into a named assertion
+ // failure on outcome() instead. Well clear of the six attempts a clamped run needs.
+ final int stopAfterAttempts = BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS * 5;
+ final BackgroundDrainer[] ref = new BackgroundDrainer[1];
+ AtomicInteger scripted = new AtomicInteger();
+ ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
+ if (scripted.incrementAndGet() >= stopAfterAttempts) {
+ ref[0].requestStop();
+ }
+ return new QwpAuthFailedException(401, "127.0.0.1", 9000);
+ }).withDynamicCredential();
+
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ factory, Long.MAX_VALUE, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+ ref[0] = drainer;
+ drainer.ageDynamicCredentialAuthAnchorForTesting(
+ TimeUnit.MILLISECONDS.toNanos(anchorAgeMillis));
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ assertNull(drainer.connectWithDurableAckRetry());
+ // FAILED, not STOPPED: STOPPED means the loop was still riding out rejections when the factory
+ // pulled the plug, which is precisely what an unclamped dwell does.
+ assertEquals("a saturated reconnect_max_duration_millis must not disable the escalation - the "
+ + "connect loop has to use the CLAMPED dwell [attempts=" + factory.attempts() + "]",
+ BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ // The dwell was already satisfied before the first sweep, so the attempt threshold is what the
+ // quarantine waited for - it must fire on exactly that attempt, not later.
+ assertEquals("quarantine must fall on the attempt threshold once the dwell is behind it",
+ BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS, factory.attempts());
+ assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ assertEquals("exactly one abandonment report: " + captured, 1, captured.size());
+ assertEquals(SenderError.Category.DATA_LOSS, captured.get(0).getCategory());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testAlternating401AndOutageStillReachesTheEscalation() throws Exception {
+ assertMemoryLeak(() -> {
+ // The two tests below prove an unrelated outage must not let the dwell be satisfied for free,
+ // and they restart the dwell anchor to get it. Taken alone that leaves the AND gate
+ // unsatisfiable: the anchor is rewound by the capability-gap, role-reject and transport arms,
+ // while the attempt counter is only ever reset by real ack progress. A cluster that alternates
+ // - reject, blip, reject, blip - therefore re-anchors before every rejection, elapsed is always
+ // ~0, the second disjunct is permanently true, and connectWithDurableAckRetry() never returns:
+ // the slot is never quarantined, no DATA_LOSS is reported, and one of max_background_drainers
+ // workers is pinned for the life of the process.
+ //
+ // The attempt cap is what closes it. It cannot be rewound by an unrelated state, so it bounds
+ // the episode however the rejections are spaced, while leaving the dwell to decide every case
+ // that is not pathological. Without it this test does not fail an assertion - it never returns
+ // and dies on the @Test timeout.
+ final AtomicInteger calls = new AtomicInteger();
+ CursorWebSocketSendLoop.ReconnectFactory alternating = new CursorWebSocketSendLoop.ReconnectFactory() {
+ @Override
+ public boolean hasDynamicCredential() {
+ return true;
+ }
+
+ @Override
+ public WebSocketClient reconnect() {
+ // strict alternation: no two rejections are ever consecutive, so the dwell anchor is
+ // reset before each one and never accumulates
+ if (calls.incrementAndGet() % 2 == 1) {
+ throw new QwpAuthFailedException(401, "127.0.0.1", 9000);
+ }
+ throw new RuntimeException("cluster unreachable");
+ }
+ };
+ // A dwell far larger than anything this test can accumulate, so ONLY the cap can end it.
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ alternating, Long.MAX_VALUE, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+
+ assertNull("an alternating credential rejection must still reach the escalation",
+ drainer.connectWithDurableAckRetry());
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertTrue("the cap must be the backstop, not the first line: the ordinary dwell path has to "
+ + "get its full attempt threshold first",
+ calls.get() >= BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS);
+ assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testTransientOutageDoesNotCountTowardTheRotating401Dwell() throws Exception {
+ assertMemoryLeak(() -> {
+ // The dwell measures how long the REJECTION persisted, so an unrelated outage in the middle of a
+ // 401 run is not part of it. Anchored at the first 401 and never restarted, a 401, then an outage
+ // outlasting the dwell, then a sixth rejection satisfied both thresholds at once and quarantined
+ // the slot on a credential that had been rejected for seconds - abandoning replayable rows behind
+ // a .failed sentinel nothing in production clears.
+ final long dwellMillis = 100L;
+ AtomicInteger scripted = new AtomicInteger();
+ ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
+ if (scripted.incrementAndGet() == 6) {
+ // an unrelated cluster outage, longer than the whole dwell
+ Os.sleep(dwellMillis * 3);
+ return new RuntimeException("cluster unreachable");
+ }
+ return new QwpAuthFailedException(401, "127.0.0.1", 9000);
+ }).withDynamicCredential();
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ factory, dwellMillis, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ assertNull(drainer.connectWithDurableAckRetry());
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+
+ // five 401s, the outage, then the sixth 401 - attempt seven overall. Charging the outage to the
+ // dwell quarantines exactly there; restarting it means the sixth rejection must be followed by a
+ // fresh dwell of uninterrupted 401s first.
+ // The configured dwell here is far below MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS, so the clamp
+ // is inert and the dwell is unambiguously what ends the ride-out.
+ assertTrue("the outage must not have satisfied the dwell [attempts=" + factory.attempts() + "]",
+ factory.attempts() > 7);
+ assertEquals("exactly one abandonment report: " + captured, 1, captured.size());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testCapabilityGapDoesNotCountTowardTheRotating401Dwell() throws Exception {
+ assertMemoryLeak(() -> {
+ // The same defect as the transient case, in the one arm that arm did not cover. A durable-ack
+ // capability gap means we REACHED a node and it answered - it simply cannot do durable ack - so
+ // it is not time the credential spent rejected. Its own settle budget can legitimately run for
+ // the whole reconnect budget, which is exactly the span the rotating-401 dwell is meant to
+ // require of an UNINTERRUPTED rejection, so charging it lets a rolling upgrade satisfy that
+ // floor for free and quarantine a slot over a credential rejected for seconds.
+ final long dwellMillis = 100L;
+ AtomicInteger scripted = new AtomicInteger();
+ ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
+ if (scripted.incrementAndGet() == 6) {
+ // one gap sweep, longer than the whole dwell. The first gap charges nothing to the
+ // capability-gap episode (lastCapabilityGapNanos is still 0), so it cannot escalate on
+ // its own and the rotating-401 accounting is what this observes.
+ Os.sleep(dwellMillis * 3);
+ return new QwpDurableAckMismatchException("h", 1234, "primary");
+ }
+ return new QwpAuthFailedException(401, "127.0.0.1", 9000);
+ }).withDynamicCredential();
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ factory, dwellMillis, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+ List captured = Collections.synchronizedList(new ArrayList());
+ drainer.setErrorSink(captured::add);
+
+ assertNull(drainer.connectWithDurableAckRetry());
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+
+ // five 401s, the gap, then the sixth 401 - attempt seven overall. Charging the gap to the dwell
+ // quarantines exactly there; restarting it means the sixth rejection must be followed by a fresh
+ // dwell of uninterrupted 401s first.
+ assertTrue("a capability gap must not have satisfied the dwell [attempts="
+ + factory.attempts() + "]",
+ factory.attempts() > 7);
+ assertEquals("exactly one abandonment report: " + captured, 1, captured.size());
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testFlappingCapabilityGapEscalatesAcrossMidDrainRecycles() throws Exception {
+ assertMemoryLeak(() -> {
+ // The capability-gap half of the counters-as-fields fix, which nothing else pins. Every other
+ // capability-gap test drives ONE connectWithDurableAckRetry() call whose factory rejects
+ // continuously, so the settle budget is spent inside that single call and the field-vs-local
+ // distinction never shows. Reverting capabilityGapAttempts to a method local leaves all of
+ // them green.
+ //
+ // The shape that needs a field is a cluster that flaps: connect accepted, drain, mid-drain
+ // durable-ack terminal, run() re-enters connectWithDurableAckRetry(), repeat. One gap sweep
+ // per call refills a local budget on every recycle, so 16 consecutive sweeps never accumulate
+ // and the slot is never quarantined - the drainer sweeps forever holding the slot lock and one
+ // of max_background_drainers workers.
+ //
+ // Only the ATTEMPT counter can escalate here, which is what makes this discriminating: the
+ // wall-clock half (capabilityGapElapsedNanos, lastCapabilityGapNanos) is deliberately per-call,
+ // and with a single gap per call lastCapabilityGapNanos is still 0 when it is charged, so the
+ // episode clock stays at zero however many recycles run.
+ final AtomicInteger calls = new AtomicInteger();
+ CursorWebSocketSendLoop.ReconnectFactory flapping = new CursorWebSocketSendLoop.ReconnectFactory() {
+ @Override
+ public WebSocketClient reconnect() {
+ // one capability gap, then let the connect through - the recycle-forever shape
+ if (calls.incrementAndGet() % 2 == 1) {
+ throw new QwpDurableAckMismatchException("h", 1234, "primary");
+ }
+ return stubClient();
+ }
+ };
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ flapping, Long.MAX_VALUE, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+
+ WebSocketClient out = null;
+ int recycles = 0;
+ for (; recycles < 40; recycles++) {
+ out = drainer.connectWithDurableAckRetry();
+ if (out == null) {
+ break;
+ }
+ Os.sleep(2); // stand in for the drain between two mid-drain terminals
+ }
+
+ assertNull("a flapping capability gap must reach the escalation instead of recycling forever",
+ out);
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertTrue("it must spend the whole settle budget, not escalate at the first recycle "
+ + "[recycles=" + recycles + "]",
+ recycles >= BackgroundDrainer.DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS - 1);
+ assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ });
+ }
+
+ @Test(timeout = 60_000)
+ public void testFlappingCredentialEscalatesAcrossMidDrainRecycles() throws Exception {
+ assertMemoryLeak(() -> {
+ // run() re-enters connectWithDurableAckRetry() after every mid-drain terminal. While the
+ // escalation counters were locals, each recycle refilled the budget it is meant to spend, so a
+ // cluster that flaps - connect accepted, drop, 401, recycle, repeat - looped forever with no ack
+ // progress: no quarantine, the slot lock never released, and one of max_background_drainers
+ // workers (four by default) pinned, starving every other orphan slot of a drainer.
+ //
+ // Driven by calling connectWithDurableAckRetry() repeatedly, which is what the recycle does.
+ final AtomicInteger calls = new AtomicInteger();
+ CursorWebSocketSendLoop.ReconnectFactory flapping = new CursorWebSocketSendLoop.ReconnectFactory() {
+ @Override
+ public boolean hasDynamicCredential() {
+ return true;
+ }
+
+ @Override
+ public WebSocketClient reconnect() {
+ // reject once, then let the connect through - the shape that recycles forever
+ if (calls.incrementAndGet() % 2 == 1) {
+ throw new QwpAuthFailedException(401, "127.0.0.1", 9000);
+ }
+ return stubClient();
+ }
+ };
+ BackgroundDrainer drainer = newDrainerWithBudgets(
+ flapping, 25L, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
+
+ WebSocketClient out = null;
+ int recycles = 0;
+ for (; recycles < 40; recycles++) {
+ out = drainer.connectWithDurableAckRetry();
+ if (out == null) {
+ break;
+ }
+ Os.sleep(2); // stand in for the drain between two mid-drain terminals
+ }
+
+ assertNull("a flapping credential must reach the escalation instead of recycling forever", out);
+ assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ assertTrue("it must escalate once both thresholds are met, not at the very first recycle "
+ + "[recycles=" + recycles + "]",
+ recycles >= BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS - 1);
+ assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ });
+ }
+
+ @Test(timeout = 60_000)
public void testReturnsClientOnSuccessFirstAttempt() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -291,7 +706,7 @@ public void testReturnsClientOnSuccessFirstAttempt() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testRetriesOnDurableAckMismatchThenSucceeds() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -313,7 +728,7 @@ public void testRetriesOnDurableAckMismatchThenSucceeds() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testStopRequestedDuringRetryAbortsWithStoppedOutcome() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -348,7 +763,7 @@ public void testStopRequestedDuringRetryAbortsWithStoppedOutcome() throws Except
});
}
- @Test
+ @Test(timeout = 60_000)
public void testWallTimeBudgetEscalatesBeforeAttemptCap() throws Exception {
assertMemoryLeak(() -> {
CountingListener listener = new CountingListener();
@@ -381,7 +796,7 @@ public void testWallTimeBudgetEscalatesBeforeAttemptCap() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testAllReplicaWindowNeverEscalatesInvariantB() throws Exception {
assertMemoryLeak(() -> {
// INVARIANT B (orphan drainer): a store-and-forward drainer must NEVER
@@ -399,11 +814,12 @@ public void testAllReplicaWindowNeverEscalatesInvariantB() throws Exception {
// problem and stays terminal. This test uses a role reject (every
// endpoint is a replica right now), which must NOT be terminal.
//
- // Red-first: connectWithDurableAckRetry() currently lumps role rejects in
- // with the durable-ack-mismatch give-up, so after the 16-attempt cap /
- // the budget it markFailed()s and returns -> the helper thread dies. Goes
- // green once the drainer treats an all-replica window as retry-forever
- // (split the catch: role reject -> retry; capability gap -> quarantine).
+ // The regression this pins: lumping role rejects in with the
+ // durable-ack-mismatch give-up. Under that shape the 16-attempt cap or
+ // the wall-clock budget markFailed()s and returns, so the helper thread
+ // started below dies inside the observation window. The drainer keeps the
+ // two apart - a role reject backs off and retries, a capability gap
+ // quarantines - which is what the still-alive assertions rest on.
CountingListener listener = new CountingListener();
AtomicInteger attempts = new AtomicInteger();
ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
@@ -452,7 +868,7 @@ public void testAllReplicaWindowNeverEscalatesInvariantB() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testTransportErrorNeverQuarantinesInvariantB() throws Exception {
assertMemoryLeak(() -> {
// INVARIANT B (orphan drainer): a fully-unreachable cluster (server down,
@@ -464,11 +880,12 @@ public void testTransportErrorNeverQuarantinesInvariantB() throws Exception {
// (CursorWebSocketSendLoop.connectLoop: a transport error backs off and
// retries), which the orphan drainer must match.
//
- // Red-first: connectWithDurableAckRetry() currently routes any non-role,
- // non-durable-ack Throwable (including "all endpoints unreachable") to an
- // IMMEDIATE markFailed / .failed sentinel on the first attempt. Green once
- // transport errors are retried indefinitely like connectLoop. (Genuine
- // terminals -- auth / non-421 upgrade -- must still fail fast.)
+ // The regression this pins: routing any non-role, non-durable-ack
+ // Throwable - "all endpoints unreachable" included - to an IMMEDIATE
+ // markFailed / .failed sentinel on the first attempt. The catch-all
+ // retries a transport failure indefinitely, exactly as connectLoop does;
+ // the genuine terminals (auth, non-421 upgrade, durable-ack capability
+ // gap) are caught ahead of it and still fail fast.
CountingListener listener = new CountingListener();
AtomicInteger attempts = new AtomicInteger();
ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
@@ -514,7 +931,7 @@ public void testTransportErrorNeverQuarantinesInvariantB() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testJvmErrorEscapesConnectRetryLoop() throws Exception {
assertMemoryLeak(() -> {
// Regression (M3): catch (Throwable) in connectWithDurableAckRetry used
@@ -546,7 +963,7 @@ public void testJvmErrorEscapesConnectRetryLoop() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testRoleRejectChurnDoesNotConsumeCapabilityGapBudgetInvariantB() throws Exception {
assertMemoryLeak(() -> {
// Rolling-upgrade interleave: a long all-replica window (role rejects),
@@ -593,7 +1010,7 @@ public void testRoleRejectChurnDoesNotConsumeCapabilityGapBudgetInvariantB() thr
});
}
- @Test
+ @Test(timeout = 60_000)
public void testFailoverWindowDoesNotBurnCapabilityGapWallClockInvariantB() throws Exception {
assertMemoryLeak(() -> {
// The wall-clock half of the settle budget must be anchored at the
@@ -636,7 +1053,7 @@ public void testFailoverWindowDoesNotBurnCapabilityGapWallClockInvariantB() thro
});
}
- @Test
+ @Test(timeout = 60_000)
public void testRoleRejectResetsCapabilityGapEpisode() throws Exception {
assertMemoryLeak(() -> {
// An intervening role reject proves the topology changed (the node
@@ -682,7 +1099,7 @@ public void testRoleRejectResetsCapabilityGapEpisode() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testRoleRejectAndCapabilityGapLandOnSeparateStreams() throws Exception {
assertMemoryLeak(() -> {
// M10 discriminator: gap -> role reject -> gap -> success. The
@@ -720,7 +1137,7 @@ public void testRoleRejectAndCapabilityGapLandOnSeparateStreams() throws Excepti
});
}
- @Test
+ @Test(timeout = 60_000)
public void testSaturatingCapabilityGapBudgetDoesNotQuarantineOnTheFirstSweep() throws Exception {
assertMemoryLeak(() -> {
// reconnect_max_duration_millis is validated only as > 0, and Long.MAX_VALUE
@@ -746,7 +1163,7 @@ public void testSaturatingCapabilityGapBudgetDoesNotQuarantineOnTheFirstSweep()
});
}
- @Test
+ @Test(timeout = 60_000)
public void testTransportErrorResetsCapabilityGapEpisode() throws Exception {
assertMemoryLeak(() -> {
// A transport state breaks a consecutive capability-gap episode.
@@ -779,7 +1196,7 @@ public void testTransportErrorResetsCapabilityGapEpisode() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testTransportWindowResetsCapabilityGapWallClock() throws Exception {
assertMemoryLeak(() -> {
// The wall-clock half of the settle budget is anchored at gap #1.
@@ -824,7 +1241,7 @@ public void testTransportWindowResetsCapabilityGapWallClock() throws Exception {
});
}
- @Test
+ @Test(timeout = 60_000)
public void testRoleRejectGrantsFreshWallClockToNextGapEpisode() {
// Companion to testRoleRejectResetsCapabilityGapEpisode, which pins the
// ATTEMPT-counter half of the episode reset but runs under a 60s budget
@@ -883,7 +1300,7 @@ public void testRoleRejectGrantsFreshWallClockToNextGapEpisode() {
assertEquals(Collections.singletonList(1), listener.primaryUnavailableAttempts);
}
- @Test
+ @Test(timeout = 60_000)
public void testRequestStopInterruptsLongBackoffParkPromptly() throws Exception {
// Pins the stop-promptness contract of the backoff park: requestStop()
// must break the drainer out of a LONG park (unpark, backstopped by
@@ -942,7 +1359,7 @@ private BackgroundDrainer newDrainer(ScriptedFactory factory) {
}
private BackgroundDrainer newDrainerWithBudgets(
- ScriptedFactory factory,
+ CursorWebSocketSendLoop.ReconnectFactory factory,
long reconnectMaxDurationMillis,
long backoffInitMillis,
long backoffMaxMillis) {
@@ -1032,6 +1449,9 @@ private static final class ScriptedFactory implements CursorWebSocketSendLoop.Re
private final WebSocketClient successSentinel;
private final ThrowableSupplier throwSupplier;
private final int throwingTimes;
+ // models a sender wired to an httpTokenProvider: the Authorization header is re-derived on every
+ // attempt, so a 401 can be a window that heals rather than a permanent misconfiguration
+ private boolean dynamicCredential;
ScriptedFactory(WebSocketClient successSentinel,
int throwingTimes,
@@ -1057,6 +1477,22 @@ int attempts() {
return calls.get();
}
+ /**
+ * The signal BackgroundDrainer branches its terminal policy on. Stubbed here, deliberately: these
+ * tests pin the POLICY (fail fast on a constant credential, ride out the settle budget on a rotating
+ * one), not the classification. What decides it in production is
+ * {@code QwpWebSocketSender.hasDynamicCredential()} - a {@code FixedAuthHeader} identity check on the
+ * configured supplier - and that is pinned on a real built sender, for httpToken,
+ * httpUsernamePassword, httpTokenProvider and no-credential alike, by
+ * {@code WebSocketTokenProviderTest.testCredentialKindTaggedForTheOrphanDrainerTerminalPolicy},
+ * which reads it both directly and through the background reconnect factory a drainer is handed.
+ * Neither half means much without the other: keep them named in each other's comments.
+ */
+ @Override
+ public boolean hasDynamicCredential() {
+ return dynamicCredential;
+ }
+
@Override
public WebSocketClient reconnect() throws Exception {
int n = calls.incrementAndGet();
@@ -1073,6 +1509,11 @@ public WebSocketClient reconnect() throws Exception {
WebSocketClient successSentinel() {
return successSentinel;
}
+
+ ScriptedFactory withDynamicCredential() {
+ this.dynamicCredential = true;
+ return this;
+ }
}
/**
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java
new file mode 100644
index 000000000..09a76656f
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java
@@ -0,0 +1,366 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
+import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.std.Files;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Mid-drain rotating-credential 401/403 coverage for {@link BackgroundDrainer}.
+ *
+ * {@code connectWithDurableAckRetry} gives an ORPHAN drainer whose credential
+ * rotates ({@code hasDynamicCredential()}) a bounded ride-out requiring both
+ * an attempt threshold ({@link BackgroundDrainer#DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS})
+ * and a wall-clock dwell floor before quarantining on a 401 — the header is
+ * re-derived from the token provider every attempt, so a rejection can be a self-healing window (a
+ * revocation landing mid-flight, the IdP rotating signing keys, clock skew) a
+ * freshly pulled token clears. The same rejection hit mid-drain (the
+ * wire drops, the loop's reconnect sweep is refused) must get the same ride-out
+ * rather than dropping a {@code .failed} sentinel on the first sweep — otherwise
+ * a token rotation during an in-progress drain permanently abandons replayable
+ * data on a fault that heals in seconds. The initial-connect ride-out
+ * ({@link BackgroundDrainerDurableAckRetryTest}) never exercised the mid-drain
+ * reconnect, which the ORPHAN {@link CursorWebSocketSendLoop} handles.
+ *
+ * A CONSTANT credential still quarantines on the first mid-drain 401: it is
+ * uniformly rejected across the cluster and will not heal. The sanctioned
+ * terminal set is otherwise unchanged.
+ *
+ * Wire realism: a real {@link TestWebSocketServer} durably acks over a live
+ * socket; the scripted {@link CursorWebSocketSendLoop.ReconnectFactory} decides,
+ * per connect attempt, whether the sweep reaches a healthy node or is refused
+ * with a 401. The mid-drain drop is deterministic — the server closes the first
+ * connection after durably acking exactly one frame.
+ */
+public class BackgroundDrainerMidDrainAuthRejectTest {
+
+ private static final long FAST_BACKOFF_MAX_MILLIS = 4L;
+ private static final long FAST_BACKOFF_MILLIS = 1L;
+ private static final long RECONNECT_MAX_DURATION_MILLIS = 25L;
+ private static final int SEEDED_FRAMES = 5;
+ private static final long SEGMENT_SIZE_BYTES = 16_384L;
+ private static final long SF_MAX_TOTAL_BYTES = 1L << 20;
+
+ private String slotPath;
+
+ // one shared temp-directory mechanism instead of a per-class java.io.tmpdir path plus a hand-rolled
+ // recursive delete: the rule cleans up on failure and on an exception thrown out of a test too
+ @Rule
+ public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+ @Before
+ public void setUp() {
+ slotPath = temp.getRoot().toPath().resolve("slot").toString();
+ assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT));
+ }
+
+
+ @Test
+ public void testMidDrainConstantCredential401QuarantinesImmediately() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ seedSlot(SEEDED_FRAMES);
+ DropFirstHandler handler = new DropFirstHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ // Same mid-drain 401, but the credential is CONSTANT: no ride-out,
+ // it must quarantine on the first sweep exactly as before the fix.
+ ScriptedWireFactory factory = new ScriptedWireFactory(
+ server.getPort(), 2, Integer.MAX_VALUE, /* dynamicCredential */ false);
+ BackgroundDrainer drainer = newDrainer(factory);
+ List captured = Collections.synchronizedList(new ArrayList