From 86d65ffbb00068cd0b15d2cfde32775b51555b90 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 17 Jun 2026 16:58:44 +0100 Subject: [PATCH 001/192] feat(core): OIDC device flow --- README.md | 55 + .../io/questdb/client/HttpTokenProvider.java | 48 + .../main/java/io/questdb/client/Sender.java | 49 +- .../auth/DeviceAuthorizationChallenge.java | 91 + .../client/cutlass/auth/DeviceCodePrompt.java | 68 + .../cutlass/auth/OidcAuthException.java | 106 ++ .../client/cutlass/auth/OidcDeviceAuth.java | 1236 ++++++++++++ .../client/cutlass/http/client/Response.java | 9 + .../client/cutlass/json/JsonLexer.java | 90 +- .../line/http/AbstractLineHttpSender.java | 33 +- .../test/SenderBuilderErrorApiTest.java | 38 + .../test/cutlass/auth/MockOidcServer.java | 266 +++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 1692 +++++++++++++++++ .../test/cutlass/json/JsonLexerTest.java | 37 +- .../example/sender/OidcDeviceFlowExample.java | 44 + 15 files changed, 3854 insertions(+), 8 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/HttpTokenProvider.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java create mode 100644 examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java diff --git a/README.md b/README.md index ab127c6e8..3c8a6bd02 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,61 @@ try (Sender sender = Sender.fromConfig("https::addr=localhost:9000;tls_verify=un } ``` +### OIDC Sign-In (Device Flow) + +For QuestDB Enterprise instances secured with OIDC, `OidcDeviceAuth` signs a user in interactively using the [OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628). It works from environments that have no local browser — a remote notebook kernel, a container, a headless job — because the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider. + +On first use it prints a verification URL and a short code; open the URL, enter the code, and the token is cached in memory and refreshed silently on later calls. + +```java +import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.OidcDeviceAuth; + +// Discover the client id, scope and endpoints from the QuestDB server's /settings: +try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { + auth.getToken(); // sign in once: prompts on first use, then caches and refreshes + + // Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each + // request, so a long-lived sender keeps working as the token rotates. getTokenSilently() refreshes + // silently and never prompts on the flush path. + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("questdb.example.com:9000") + .enableTls() + .httpTokenProvider(auth::getTokenSilently) + .build()) { + sender.table("trades") + .symbol("symbol", "ETH-USD") + .doubleColumn("price", 2615.54) + .atNow(); + } +} +``` + +Prefer `httpTokenProvider(auth::getTokenSilently)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. + +The same token can be presented to QuestDB over any auth path the server already validates: + +- **REST API:** send it as an `Authorization: Bearer ` 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(...)` needs a server that advertises its device authorization endpoint through `/settings`, and the identity provider's client must have the device authorization grant enabled. + +By default the device authorization and token endpoints must use `https`, so tokens are never sent in cleartext; an `http` endpoint is rejected. For local development against an `http` endpoint, opt in explicitly with `.allowInsecureTransport(true)` on the builder, or `OidcDeviceAuth.fromQuestDB(url, true)`. + +`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` instead of discovering it. + ### 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..3e540320a --- /dev/null +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -0,0 +1,48 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client; + +/** + * Supplies an HTTP authentication token to a {@link Sender} on demand. The sender calls + * {@link #getToken()} as it builds each request, so a provider that returns a freshly refreshed + * token - for example {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender + * authenticated as the token rotates, without rebuilding the sender. + *

+ * {@link #getToken()} runs on the sender's flush path, so it must return promptly and must not + * block on interactive input. It may perform a quick silent token refresh, but must not start an + * interactive sign-in. An exception thrown from {@link #getToken()} fails the current flush. + * + * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider) + */ +@FunctionalInterface +public interface HttpTokenProvider { + /** + * Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the + * sender adds it). Must not return null or an empty value. + * + * @return the current HTTP authentication token + */ + CharSequence getToken(); +} diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 8e9513b11..dc2297665 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1034,6 +1034,7 @@ final class LineSenderBuilder { private String httpSettingsPath; private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY; private String httpToken; + private HttpTokenProvider httpTokenProvider; // Drives the initial-connect strategy. null means "not set // explicitly", which build() resolves to SYNC when any reconnect_* // knob was tuned by the user, otherwise OFF. SYNC retries on the @@ -1365,7 +1366,7 @@ public Sender build() { tlsConfig = new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode == TlsValidationMode.DEFAULT ? ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL : ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE); } return AbstractLineHttpSender.createLineSender(hosts, ports, httpPath, httpClientConfiguration, tlsConfig, actualAutoFlushRows, httpToken, - username, password, maxNameLength, actualMaxRetriesNanos, maxBackoffMillis, actualMinRequestThroughput, actualAutoFlushIntervalMillis, protocolVersion); + username, password, maxNameLength, actualMaxRetriesNanos, maxBackoffMillis, actualMinRequestThroughput, actualAutoFlushIntervalMillis, protocolVersion, httpTokenProvider); } if (protocol == PROTOCOL_WEBSOCKET) { @@ -1998,6 +1999,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"); } @@ -2005,6 +2009,37 @@ public LineSenderBuilder httpToken(String token) { return this; } + /** + * Supplies the HTTP authentication token from a provider that the sender queries as it builds + * each request, instead of a fixed {@link #httpToken(String) token} captured once. This keeps a + * long-lived sender following token refreshes - for example a token obtained through the OIDC + * device flow: {@code .httpTokenProvider(auth::getTokenSilently)}. + *
+ * The provider runs on the flush path, so it must return promptly and must not block on + * interactive input (see {@link HttpTokenProvider}). Only valid for HTTP transport, and mutually + * exclusive with {@link #httpToken(String)} and {@link #httpUsernamePassword(String, String)}. + * + * @param httpTokenProvider supplies the current HTTP authentication token + * @return this instance for method chaining + */ + public LineSenderBuilder httpTokenProvider(HttpTokenProvider httpTokenProvider) { + if (this.username != null) { + throw new LineSenderException("authentication username was already configured ") + .put("[username=").put(this.username).put("]"); + } + if (this.httpToken != null) { + throw new LineSenderException("token was already configured"); + } + if (this.httpTokenProvider != null) { + throw new LineSenderException("token provider was already configured"); + } + if (httpTokenProvider == null) { + throw new LineSenderException("token provider cannot be null"); + } + this.httpTokenProvider = httpTokenProvider; + return this; + } + /** * Use username and password for authentication when communicating over HTTP or WebSocket protocol. *
@@ -2030,6 +2065,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; @@ -3435,6 +3473,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"); } @@ -3460,6 +3501,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"); } @@ -3503,6 +3547,9 @@ private void validateParameters() { if (httpToken != null && (username != null || password != null)) { throw new LineSenderException("cannot use both token and username/password authentication"); } + if (httpTokenProvider != null) { + throw new LineSenderException("HTTP token provider authentication is not supported for WebSocket protocol"); + } if (httpPath != null) { throw new LineSenderException("HTTP path is not supported for WebSocket protocol"); } 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..5235fa1d5 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java @@ -0,0 +1,91 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.auth; + +/** + * The user-facing part of an RFC 8628 device authorization response: the code the + * user has to type and the URL where they type it. A {@link DeviceCodePrompt} + * receives this object and is responsible for showing it to the user. + *

+ * The {@code device_code} secret is deliberately not exposed here; it stays inside + * {@link OidcDeviceAuth} and is never shown to the user. + */ +public class DeviceAuthorizationChallenge { + private final int expiresInSeconds; + private final int intervalSeconds; + private final String userCode; + private final String verificationUri; + private final String verificationUriComplete; + + public DeviceAuthorizationChallenge( + String userCode, + String verificationUri, + String verificationUriComplete, + int expiresInSeconds, + int intervalSeconds + ) { + this.userCode = userCode; + this.verificationUri = verificationUri; + this.verificationUriComplete = verificationUriComplete; + this.expiresInSeconds = expiresInSeconds; + this.intervalSeconds = intervalSeconds; + } + + /** + * @return how long, in seconds, the {@link #getUserCode() user code} stays valid. + */ + public int getExpiresInSeconds() { + return expiresInSeconds; + } + + /** + * @return the minimum number of seconds the client must wait between polls. + */ + public int getIntervalSeconds() { + return intervalSeconds; + } + + /** + * @return the code the user has to enter at the {@link #getVerificationUri() verification URL}. + */ + public String getUserCode() { + return userCode; + } + + /** + * @return the URL the user has to open to authorize the device. + */ + public String getVerificationUri() { + return verificationUri; + } + + /** + * @return a URL that already embeds the user code, so the user does not have to type it, + * or {@code null} when the identity provider does not supply one. + */ + public String getVerificationUriComplete() { + return verificationUriComplete; + } +} 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..184d09822 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java @@ -0,0 +1,68 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.auth; + +import io.questdb.client.std.str.StringSink; + +/** + * Shows an RFC 8628 device authorization challenge to the user, who then opens the + * verification URL in any browser (on the same machine or on a phone) and enters the + * code. {@link OidcDeviceAuth} calls this once per interactive sign-in, just before it + * starts polling the token endpoint. + *

+ * The {@link #SYSTEM_OUT default implementation} prints the instructions to + * {@code System.out}. Supply your own implementation to render the challenge somewhere + * else, for example as a clickable link or a QR code in a notebook. + */ +@FunctionalInterface +public interface DeviceCodePrompt { + + /** + * Prints the sign-in instructions to {@code System.out} using plain ASCII text. + */ + DeviceCodePrompt SYSTEM_OUT = challenge -> { + String newLine = System.lineSeparator(); + StringSink sb = new StringSink(); + sb.put(newLine); + sb.put("=== QuestDB OIDC sign-in ===").put(newLine); + sb.put("To sign in, open this URL in a browser:").put(newLine); + sb.put(" ").put(challenge.getVerificationUri()).put(newLine); + sb.put("and enter the code: ").put(challenge.getUserCode()).put(newLine); + if (challenge.getVerificationUriComplete() != null) { + sb.put("(or open this URL, the code is already filled in:").put(newLine); + sb.put(" ").put(challenge.getVerificationUriComplete()).put(')').put(newLine); + } + sb.put("Waiting for authorization, up to ").put(challenge.getExpiresInSeconds()).put(" seconds..."); + System.out.println(sb); + }; + + /** + * Shows the challenge to the user. This method must return quickly; the actual waiting + * for the user happens afterwards while {@link OidcDeviceAuth} polls the token endpoint. + * + * @param challenge the user code, verification URL and timing parameters to show + */ + void promptUser(DeviceAuthorizationChallenge challenge); +} 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..d10d7001e --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -0,0 +1,106 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/** + * Thrown when the OIDC device authorization flow cannot obtain a token. The message is built + * with the fluent {@link #put(CharSequence)} family, backed by a {@link StringSink}. + *

+ * When the failure originates from an OAuth error response (RFC 6749 / RFC 8628), + * {@link #getOauthError()} returns the machine-readable error code (for example + * {@code access_denied} or {@code expired_token}); otherwise it returns {@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 out of 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; + } + + @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 control characters stripped, so an attacker-influenced IdP error + // string cannot inject ANSI escapes or forge log lines when the exception message is rendered + private void putSanitized(CharSequence cs) { + if (cs != null) { + for (int i = 0, n = cs.length(); i < n; i++) { + char c = cs.charAt(i); + if (!Character.isISOControl(c)) { + message.put(c); + } + } + } + } +} 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..ae4acefad --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -0,0 +1,1236 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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 java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Obtains an OIDC access or id token using the OAuth 2.0 Device Authorization Grant + * (RFC 8628), so a process with no local browser (a remote notebook kernel, a container, + * a headless job) can still sign a human in. The user authorizes on any device, while the + * token request travels outbound only. + *

+ * The resulting token can be presented to QuestDB Enterprise over any of the auth paths + * the server already validates: + *

+ * Typical use, discovering everything from the QuestDB server: + *
{@code
+ * try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
+ *     String token = auth.getToken(); // 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 #getToken()} returns a cached token while it is still valid, silently refreshes it + * when a refresh token is available, and otherwise re-runs the interactive flow. The method + * is synchronized, so concurrent callers never start two sign-ins at once; the trade-off is + * that a sign-in waiting for the user holds the instance lock for the lifetime of the device + * code (up to an hour), and any other {@link #getToken()} or {@link #clearCache()} call on the + * same instance blocks behind it. To abort a sign-in that is waiting, call {@link #close()} + * from another thread: it cancels the in-flight flow, which then fails promptly with an + * {@link OidcAuthException} rather than running to the device-code timeout. + *

+ * Instances are interactive by design and hold a network connection; close them when done. + * Token state lives in memory only and does not survive a restart of the process. + */ +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"; + private static final int DEFAULT_CLOCK_SKEW_SECONDS = 30; + // how long the device code stays valid for the interactive sign-in when the identity provider's + // device authorization response omits expires_in + private static final int DEFAULT_DEVICE_CODE_TTL_SECONDS = 300; + private static final int DEFAULT_HTTP_TIMEOUT_MILLIS = 30_000; + private static final int DEFAULT_POLL_INTERVAL_SECONDS = 5; + // how long a token is cached before getToken() refreshes it, when the token response omits expires_in + private static final int DEFAULT_TOKEN_TTL_SECONDS = 300; + private static final String ERROR_AUTHORIZATION_PENDING = "authorization_pending"; + private static final String ERROR_SLOW_DOWN = "slow_down"; + private static final HttpClientConfiguration HTTP_CONFIG = DefaultHttpClientConfiguration.INSTANCE; + // Token responses carry JWTs - an id token with group claims can be several KB - and a single + // value may arrive split across HTTP response fragments. The JSON lexer stashes a split value + // and rejects it once it grows past JSON_LEXER_MAX_VALUE_BYTES, so the limit must comfortably + // exceed any real token, otherwise 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; + // a persistent transport failure while polling aborts after this many consecutive attempts, + // instead of silently retrying until the device code expires + private static final int MAX_CONSECUTIVE_POLL_ERRORS = 3; + // upper bounds on the expires_in / interval the identity provider reports, so an absurd or + // hostile value cannot overflow the poll timing arithmetic or make the client wait absurdly long + private static final int MAX_EXPIRES_IN_SECONDS = 3600; + private static final int MAX_POLL_INTERVAL_SECONDS = 300; + // cap the bytes drained from a single response so a hostile or MITM'd server cannot stream an endless + // body and wedge the thread; set far above any real OIDC JSON response + private static final int MAX_RESPONSE_BODY_BYTES = 4 * 1024 * 1024; + 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 final String audience; + private final String clientId; + private final long clockSkewMillis; + private final DeviceAuthorizationResponseParser deviceAuthParser = new DeviceAuthorizationResponseParser(); + private final Endpoint deviceAuthorizationEndpoint; + private final StringSink formSink = new StringSink(); + private final boolean groupsInToken; + private final int httpTimeoutMillis; + private final DeviceCodePrompt prompt; + private final StringSink responseStatus = new StringSink(); + private final String scope; + private final ClientTlsConfiguration tlsConfig; + private final Endpoint tokenEndpoint; + private final TokenResponseParser tokenParser = new TokenResponseParser(); + private String accessToken; + private volatile boolean closed; + private long expiresAtMillis; + private String idToken; + private JsonLexer jsonLexer; + private HttpClient plainClient; + private String refreshToken; + private HttpClient tlsClient; + + private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { + this.clientId = builder.clientId; + this.deviceAuthorizationEndpoint = Endpoint.parse(builder.deviceAuthorizationEndpoint); + this.tokenEndpoint = Endpoint.parse(builder.tokenEndpoint); + this.scope = builder.scope; + this.audience = builder.audience; + this.groupsInToken = builder.groupsInToken; + this.httpTimeoutMillis = builder.httpTimeoutMillis; + this.clockSkewMillis = builder.clockSkewSeconds * 1000L; + this.prompt = builder.prompt; + this.tlsConfig = tlsConfig; + // allocate the native JSON lexer last: an Endpoint.parse above can throw on a malformed url, + // 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 + * around it. Reads the public {@code /settings} endpoint (no auth required) and picks up + * the client id, scope, token endpoint, device authorization endpoint and the + * groups-in-token mode the server expects. + *

+ * Trust model: the token and device authorization endpoints the user signs in against are + * taken from the server's unauthenticated {@code /settings} response. A spoofed, compromised, or + * man-in-the-middled server can therefore redirect the entire sign-in to an attacker-controlled + * identity provider and harvest the user's authorization. Only call {@code fromQuestDB} against a + * server you trust, reached over {@code https} (required by default; relaxing it with + * {@link Builder#allowInsecureTransport(boolean)} removes the transport protection). When the + * server is not trusted, configure the identity provider explicitly with {@link #builder()} + * rather than discovering it. + * + * @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 (an older server, or one not configured for it) + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl) { + return fromQuestDB(questdbUrl, defaultTlsConfig(), false); + } + + /** + * Same as {@link #fromQuestDB(String)} but lets the caller permit insecure {@code http} transport + * for the QuestDB server and the discovered identity provider endpoints (see + * {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, boolean allowInsecureTransport) { + return fromQuestDB(questdbUrl, defaultTlsConfig(), allowInsecureTransport); + } + + /** + * Same as {@link #fromQuestDB(String)} but with an explicit TLS configuration, used both for + * the discovery request and for the later identity provider requests. + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig) { + return fromQuestDB(questdbUrl, tlsConfig, false); + } + + /** + * Same as {@link #fromQuestDB(String, ClientTlsConfiguration)} but lets the caller permit insecure + * {@code http} transport for the QuestDB server and the discovered identity provider endpoints + * (see {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig, boolean 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(']'); + } + if (parser.tokenEndpoint.length() == 0) { + throw new OidcAuthException().put("the QuestDB server does not advertise an OIDC token endpoint [url=").put(questdbUrl).put(']'); + } + if (parser.deviceAuthorizationEndpoint.length() == 0) { + throw new OidcAuthException() + .put("the QuestDB server does not advertise a device authorization endpoint; upgrade the server ") + .put("or configure the endpoint explicitly with OidcDeviceAuth.builder() [url=").put(questdbUrl).put(']'); + } + return builder() + .clientId(parser.clientId.toString()) + .deviceAuthorizationEndpoint(parser.deviceAuthorizationEndpoint.toString()) + .tokenEndpoint(parser.tokenEndpoint.toString()) + .scope(parser.scope.length() > 0 ? parser.scope.toString() : DEFAULT_SCOPE) + .groupsInToken(parser.groupsInToken) + .allowInsecureTransport(allowInsecureTransport) + .tlsConfig(tlsConfig) + .build(); + } + + /** + * Drops any cached token so the next {@link #getToken()} starts a fresh interactive sign-in. + */ + public synchronized void clearCache() { + throwIfClosed(); + accessToken = null; + idToken = null; + refreshToken = null; + expiresAtMillis = 0; + } + + /** + * Frees the network connections and native buffers this instance holds. If a {@link #getToken()} + * sign-in is in flight on another thread, {@code close()} cancels it, so the blocked sign-in fails + * promptly with an {@link OidcAuthException} instead of polling to the device-code timeout. Safe to + * call more than once. After close, {@link #getToken()} and {@link #clearCache()} throw. + */ + @Override + public void close() { + // flag cancellation before taking the lock: getToken() holds the monitor for the whole + // interactive flow, so close() signals the in-flight sign-in to stop with a lock-free volatile + // write, then acquires the lock - which the now-cancelled flow releases promptly - and frees the + // native resources. close() never frees while a flow holds the lock, so there is no use-after-free + closed = true; + synchronized (this) { + plainClient = Misc.free(plainClient); + tlsClient = Misc.free(tlsClient); + jsonLexer = Misc.free(jsonLexer); + } + } + + /** + * @return {@code "Bearer " + getToken()}, ready to use as the value of an HTTP + * {@code Authorization} header. + */ + public String getAuthorizationHeaderValue() { + return "Bearer " + getToken(); + } + + /** + * Returns a valid token to present to QuestDB. Returns the cached token while it is still + * valid; otherwise refreshes it silently when possible, or runs the interactive device flow. + * The returned token is the id token when the server expects groups encoded in the token, + * and the access token otherwise. + * + * @return a non-null, non-empty token + * @throws OidcAuthException if the interactive flow fails, times out, or the identity provider + * does not return the expected token + */ + public synchronized String getToken() { + throwIfClosed(); + // only a cached copy of the token getToken() actually serves counts as a cache hit; a grant + // that returned the other kind (an access token when the server wants the id token, or vice + // versa) leaves the served token null, so the flow must re-run 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) { + if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } + } + runDeviceFlow(); + return selectToken(); + } + + /** + * Returns a valid token like {@link #getToken()} but never starts the interactive device flow: + * it returns the cached token while it is valid and silently refreshes it when a refresh token is + * available, otherwise it throws. Intended as a per-request token source for a long-lived client, + * for example {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, where an + * interactive prompt on the request path would be inappropriate. Call {@link #getToken()} once to + * sign in before handing this method to a client. + * + * @return a non-null, non-empty token + * @throws OidcAuthException if no token has been obtained yet, or the cached token expired and + * could not be refreshed without an interactive sign-in + */ + public synchronized String getTokenSilently() { + throwIfClosed(); + final String cachedToken = groupsInToken ? idToken : accessToken; + if (cachedToken != null) { + if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } + throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call getToken() to sign in again"); + } + throw new OidcAuthException("no token has been obtained yet; call getToken() to sign in before using getTokenSilently()"); + } + + 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 ClientTlsConfiguration defaultTlsConfig() { + return new ClientTlsConfiguration(null, null, ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL); + } + + private static void discardBody(Response body, int timeoutMillis) { + // best-effort drain after a parse failure so the keep-alive connection stays usable; bounded the + // same way as parseBody so a hostile server cannot wedge the thread here either + 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; + } + Fragment fragment = body.recv((int) Math.max(1, Math.min(remainingNanos / 1_000_000L, Integer.MAX_VALUE))); + if (fragment == null) { + return; + } + totalBytes += fragment.hi() - fragment.lo(); + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + return; + } + } + } catch (HttpClientException ignore) { + // the connection is re-established on the next request if it is now unusable + } + } + + private static void discoverSettings(Endpoint server, ClientTlsConfiguration tlsConfig, SettingsDiscoveryParser parser) { + HttpClient client = server.isTls + ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) + : HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); + JsonLexer lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES); + try { + HttpClient.Request request = client.newRequest(server.host, server.port) + .GET() + .url(appendSettingsPath(server.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(); + // bounded read: parseBody enforces a wall-clock deadline and a byte cap so an untrusted + // server cannot wedge discovery, and its parseLast rejects a truncated /settings document + parseBody(body, lexer, parser, DEFAULT_HTTP_TIMEOUT_MILLIS); + } catch (HttpClientException e) { + throw new OidcAuthException(e).put("could not reach the QuestDB server to discover OIDC settings"); + } catch (JsonException e) { + throw new OidcAuthException(e).put("could not parse the QuestDB /settings response"); + } finally { + Misc.free(lexer); + Misc.free(client); + } + } + + private static void parseBody(Response body, JsonLexer lexer, JsonParser parser, int timeoutMillis) throws JsonException { + // read and parse the whole body, bounded by an overall wall-clock deadline and a cumulative byte + // cap, so a hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming + 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 void putValue(StringSink sink, CharSequence tag) { + // clear before storing so a repeated key in the response replaces, rather than concatenates onto, + // the previous value (the same clear-before-put guard SettingsDiscoveryParser.putNonNull applies) + sink.clear(); + sink.put(tag); + } + + 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(']'); + } + } + + private static String sanitizeForDisplay(String value) { + if (value == null) { + return null; + } + int firstControl = -1; + int n = value.length(); + for (int i = 0; i < n; i++) { + if (Character.isISOControl(value.charAt(i))) { + firstControl = i; + break; + } + } + if (firstControl < 0) { + // common case: nothing to strip + return value; + } + // an attacker-influenced device-auth field smuggled in control characters (ANSI escapes, + // CR/LF); strip them so a prompt cannot be tricked into rewriting or spoofing the terminal + StringSink sink = new StringSink(); + sink.put(value, 0, firstControl); + for (int i = firstControl + 1; i < n; i++) { + char c = value.charAt(i); + if (!Character.isISOControl(c)) { + sink.put(c); + } + } + return sink.toString(); + } + + private static String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private void appendParam(StringSink sink, String name, String value) { + sink.putAscii('&').putAscii(name).putAscii('=').putAscii(urlEncode(value)); + } + + private HttpClient httpClient(boolean isTls) { + if (isTls) { + if (tlsClient == null) { + tlsClient = HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig); + } + return tlsClient; + } + if (plainClient == null) { + plainClient = HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); + } + return plainClient; + } + + private boolean isHttpStatusSuccess() { + // responseStatus holds the numeric HTTP status captured by readResponse; a 2xx starts with '2' + return responseStatus.length() > 0 && responseStatus.charAt(0) == '2'; + } + + private int pollOnce(String deviceCode) { + formSink.clear(); + formSink.putAscii("grant_type=").putAscii(urlEncode(GRANT_TYPE_DEVICE_CODE)); + appendParam(formSink, "device_code", deviceCode); + appendParam(formSink, "client_id", clientId); + + tokenParser.clear(); + // a transport failure here propagates to pollForToken, which retries a brief blip but aborts + // on a persistent failure rather than swallowing it as a pending authorization + postForm(tokenEndpoint, tokenParser); + + if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { + storeTokens(tokenParser); + return POLL_SUCCESS; + } + if (tokenParser.error.length() == 0) { + // a 2xx with neither tokens nor an OAuth error is a definitive but malformed answer and + // aborts; a non-2xx with no parseable error (a gateway 5xx, an empty body) is a transport- + // class blip - retry it rather than abort the whole sign-in on a momentary upstream failure + if (isHttpStatusSuccess()) { + throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); + } + return POLL_TRANSIENT_ERROR; + } + 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); + } + + private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) { + final long deadlineNanos = System.nanoTime() + expiresInSeconds * 1_000_000_000L; + long intervalMillis = (long) intervalSeconds * 1000L; + int consecutiveTransportErrors = 0; + while (true) { + throwIfClosed(); + try { + int result = pollOnce(deviceCode); + if (result == POLL_SUCCESS) { + return; + } + if (result == POLL_TRANSIENT_ERROR) { + // a non-2xx with no parseable answer; charge it to the transport-error budget so a + // persistently failing token endpoint aborts instead of polling until the code expires + if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + throw new OidcAuthException().put("the token endpoint returned repeated unexpected responses [httpStatus=").put(responseStatus).put(']'); + } + } else { + consecutiveTransportErrors = 0; + if (result == POLL_SLOW_DOWN) { + intervalMillis += SLOW_DOWN_INCREMENT_SECONDS * 1000L; + } + } + } catch (HttpClientException e) { + // a brief network blip is fine to retry, but a persistent failure (a rejected TLS + // certificate, a refused connection, an unresolvable host) must surface with its cause + // rather than masquerade as a device-code timeout + if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + throw new OidcAuthException(e).put("the token endpoint became unreachable while waiting for authorization"); + } + } catch (OidcAuthException e) { + // a garbled / non-JSON body (a JsonException cause) is a transport-class blip and is + // retried on the same budget; a well-formed OAuth error or unexpected response (no + // parse cause) is a real answer from the identity provider and aborts immediately + if (!(e.getCause() instanceof JsonException)) { + throw e; + } + if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + throw e; + } + } + if (System.nanoTime() >= deadlineNanos) { + throw new OidcAuthException("timed out waiting for authorization, the device code expired; please retry"); + } + sleepBetweenPolls(intervalMillis); + } + } + + 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); + HttpClient.ResponseHeaders response = request.send(httpTimeoutMillis); + response.await(httpTimeoutMillis); + readResponse(response, parser); + } + + private void readResponse(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(); + if (statusCode != null) { + responseStatus.put(statusCode.asAsciiCharSequence()); + } + jsonLexer.clear(); + Response body = response.getResponse(); + try { + parseBody(body, jsonLexer, parser, httpTimeoutMillis); + } catch (JsonException e) { + // drain the rest so the keep-alive connection stays usable; never embed the body, it may + // carry tokens + discardBody(body, httpTimeoutMillis); + throw new OidcAuthException(e) + .put("could not parse the identity provider response [httpStatus=").put(responseStatus).put(']'); + } + } + + private void runDeviceFlow() { + formSink.clear(); + formSink.putAscii("client_id=").putAscii(urlEncode(clientId)); + appendParam(formSink, "scope", scope); + if (audience != null) { + appendParam(formSink, "audience", audience); + } + + 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); + } + if (deviceAuthParser.deviceCode.length() == 0 || deviceAuthParser.userCode.length() == 0 + || deviceAuthParser.verificationUri.length() == 0) { + throw new OidcAuthException().put("incomplete device authorization response from the identity provider [httpStatus=").put(responseStatus).put(']'); + } + + final String deviceCode = deviceAuthParser.deviceCode.toString(); + final int expiresInSeconds = boundedSeconds(deviceAuthParser.expiresIn, DEFAULT_DEVICE_CODE_TTL_SECONDS, MAX_EXPIRES_IN_SECONDS); + final int intervalSeconds = boundedSeconds(deviceAuthParser.interval, DEFAULT_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); + final DeviceAuthorizationChallenge challenge = new DeviceAuthorizationChallenge( + sanitizeForDisplay(deviceAuthParser.userCode.toString()), + sanitizeForDisplay(deviceAuthParser.verificationUri.toString()), + deviceAuthParser.verificationUriComplete.length() > 0 ? sanitizeForDisplay(deviceAuthParser.verificationUriComplete.toString()) : null, + 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) poll interval; Os.sleep ignores thread + // interrupts, so polling the closed flag is the only way to stay responsive to cancellation + long remaining = millis; + while (remaining > 0) { + throwIfClosed(); + long slice = Math.min(POLL_SLEEP_SLICE_MILLIS, remaining); + Os.sleep(slice); + remaining -= slice; + } + } + + private void storeTokens(TokenResponseParser parser) { + accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null; + idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null; + // a refresh response usually omits a new refresh token, in that case we keep the current one + if (parser.refreshToken.length() > 0) { + refreshToken = parser.refreshToken.toString(); + } + int ttlSeconds = parser.expiresIn > 0 ? parser.expiresIn : DEFAULT_TOKEN_TTL_SECONDS; + expiresAtMillis = System.currentTimeMillis() + ttlSeconds * 1000L; + } + + private void throwIfClosed() { + if (closed) { + throw new OidcAuthException("the OidcDeviceAuth instance is closed"); + } + } + + private boolean tryRefresh() { + formSink.clear(); + formSink.putAscii("grant_type=").putAscii(urlEncode(GRANT_TYPE_REFRESH_TOKEN)); + appendParam(formSink, "refresh_token", refreshToken); + appendParam(formSink, "client_id", clientId); + if (scope != null) { + appendParam(formSink, "scope", scope); + } + + 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) { + // a garbled / unparseable refresh response is a transient blip, not a definitive answer; + // fall back to the interactive flow rather than fail the whole getToken() call. A genuine + // OAuth error arrives in tokenParser.error (handled below), not as a thrown oauthError here + if (e.getOauthError() != null) { + throw e; + } + return false; + } + // only treat the refresh as a success if it returned the token getToken() actually serves + // (the id token when groups are encoded in it, the access token otherwise); a refresh that + // omits the id token - which RFC 6749 permits and many providers do - must fall back to the + // interactive flow rather than fail later in selectToken() + boolean hasRequiredToken = groupsInToken + ? tokenParser.idToken.length() > 0 + : tokenParser.accessToken.length() > 0; + if (hasRequiredToken) { + storeTokens(tokenParser); + return true; + } + // the refresh token expired or was revoked, or it did not return the token we need; + // fall back to the interactive flow + return false; + } + + /** + * 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 int clockSkewSeconds = DEFAULT_CLOCK_SKEW_SECONDS; + private String deviceAuthorizationEndpoint; + private boolean groupsInToken; + private int httpTimeoutMillis = DEFAULT_HTTP_TIMEOUT_MILLIS; + private DeviceCodePrompt prompt = DeviceCodePrompt.SYSTEM_OUT; + private String scope = DEFAULT_SCOPE; + private ClientTlsConfiguration tlsConfig; + private String tokenEndpoint; + + private Builder() { + } + + /** + * Permits insecure {@code http} (rather than {@code https}) for the device authorization and + * token endpoints. Tokens then travel in cleartext, so this is rejected by default and should + * only be enabled for local development on a trusted network. Defaults to {@code false}. + */ + public Builder allowInsecureTransport(boolean allowInsecureTransport) { + this.allowInsecureTransport = allowInsecureTransport; + return this; + } + + /** + * Sets the {@code audience} (or {@code resource}) request parameter. Some identity providers + * require it so the issued token carries the {@code aud} claim QuestDB expects. 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; + } + if (!allowInsecureTransport) { + requireSecureTransport(Endpoint.parse(deviceAuthorizationEndpoint).isTls, "device authorization endpoint", deviceAuthorizationEndpoint); + requireSecureTransport(Endpoint.parse(tokenEndpoint).isTls, "token endpoint", tokenEndpoint); + } + ClientTlsConfiguration tls = tlsConfig != null ? tlsConfig : defaultTlsConfig(); + return new OidcDeviceAuth(this, tls); + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + /** + * Sets how many seconds before the real expiry a cached token is treated as expired. Defaults + * to 30 seconds. The margin absorbs clock drift and request latency. + */ + public Builder clockSkewSeconds(int clockSkewSeconds) { + this.clockSkewSeconds = clockSkewSeconds; + 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) { + this.httpTimeoutMillis = httpTimeoutMillis; + return this; + } + + /** + * Sets how the device code challenge is shown to the user. Defaults to + * {@link DeviceCodePrompt#SYSTEM_OUT}. + */ + public Builder prompt(DeviceCodePrompt prompt) { + this.prompt = prompt != null ? prompt : DeviceCodePrompt.SYSTEM_OUT; + 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; + } + } + + 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; + private int depth; + private int field = FIELD_NONE; + + @Override + public void clear() { + deviceCode.clear(); + error.clear(); + errorDescription.clear(); + userCode.clear(); + verificationUri.clear(); + verificationUriComplete.clear(); + expiresIn = 0; + interval = 0; + depth = 0; + field = FIELD_NONE; + } + + @Override + public void onEvent(int code, CharSequence tag, int position) { + switch (code) { + case JsonLexer.EVT_OBJ_START: + depth++; + break; + case JsonLexer.EVT_OBJ_END: + depth--; + break; + case JsonLexer.EVT_NAME: + if (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 (depth == 1) { + switch (field) { + case FIELD_DEVICE_CODE: + putValue(deviceCode, tag); + break; + case FIELD_USER_CODE: + putValue(userCode, tag); + break; + case FIELD_VERIFICATION_URI: + putValue(verificationUri, tag); + break; + case FIELD_VERIFICATION_URI_COMPLETE: + putValue(verificationUriComplete, tag); + break; + case FIELD_EXPIRES_IN: + expiresIn = parseIntOrZero(tag); + break; + case FIELD_INTERVAL: + interval = parseIntOrZero(tag); + break; + case FIELD_ERROR: + putValue(error, tag); + break; + case FIELD_ERROR_DESCRIPTION: + putValue(errorDescription, tag); + break; + default: + break; + } + } + 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"); + } + int schemeEnd = url.indexOf("://"); + if (schemeEnd < 0) { + throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']'); + } + boolean isTls; + String scheme = url.substring(0, schemeEnd); + 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; + int pathStart = url.indexOf('/', hostStart); + String hostPort = pathStart < 0 ? url.substring(hostStart) : url.substring(hostStart, pathStart); + String path = pathStart < 0 ? "/" : url.substring(pathStart); + 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); + try { + port = Integer.parseInt(hostPort.substring(colon + 1)); + } catch (NumberFormatException e) { + throw new OidcAuthException().put("invalid url, could not parse the port [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(']'); + } + return new Endpoint(host, port, path, isTls); + } + } + + private static final class SettingsDiscoveryParser implements JsonParser { + 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 clientId = new StringSink(); + final StringSink deviceAuthorizationEndpoint = new StringSink(); + final StringSink scope = new StringSink(); + final StringSink tokenEndpoint = new StringSink(); + boolean groupsInToken; + boolean isOidcEnabled; + 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_OBJ_START: + depth++; + if (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 (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 (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 { + field = FIELD_NONE; + } + } else { + field = FIELD_NONE; + } + break; + case JsonLexer.EVT_VALUE: + if (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; + default: + break; + } + } + field = FIELD_NONE; + break; + default: + break; + } + } + + private static void putNonNull(StringSink sink, CharSequence tag) { + // a JSON null is delivered as the literal "null", treat it as absent; clear first so a + // duplicate key cannot concatenate onto an earlier value + sink.clear(); + if (!Chars.equals("null", tag)) { + sink.put(tag); + } + } + } + + 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; + private int depth; + private int field = FIELD_NONE; + + @Override + public void clear() { + accessToken.clear(); + error.clear(); + errorDescription.clear(); + idToken.clear(); + refreshToken.clear(); + expiresIn = 0; + depth = 0; + field = FIELD_NONE; + } + + @Override + public void onEvent(int code, CharSequence tag, int position) { + switch (code) { + case JsonLexer.EVT_OBJ_START: + depth++; + break; + case JsonLexer.EVT_OBJ_END: + depth--; + break; + case JsonLexer.EVT_NAME: + if (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 (depth == 1) { + switch (field) { + 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_IN: + expiresIn = parseIntOrZero(tag); + break; + case FIELD_ERROR: + putValue(error, tag); + break; + case FIELD_ERROR_DESCRIPTION: + putValue(errorDescription, tag); + break; + default: + break; + } + } + break; + default: + break; + } + } + } +} 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..2a0992663 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 @@ -34,4 +34,13 @@ public interface Response { * @return the received fragment */ Fragment recv(); + + /** + * Receives the next fragment of response data, blocking at most {@code timeout} milliseconds for + * a socket read. + * + * @param timeout the receive timeout in milliseconds + * @return the received fragment, or null once the body has been fully read + */ + Fragment recv(int timeout); } 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..528deb0ea 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,6 +55,7 @@ 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; @@ -286,6 +287,18 @@ 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++) { + int digit = Character.digit(value.charAt(offset + j), 16); + if (digit < 0) { + return -1; + } + result = (result << 4) | digit; + } + return result; + } + private static JsonException unsupportedEncoding(int position) { return JsonException.$(position, "Unsupported encoding"); } @@ -328,7 +341,82 @@ private CharSequence getCharSequence(long lo, long hi, int position) throws Json } else { utf8DecodeCacheAndBuffer(lo, hi - 1, position); } - return sink; + // the decode above assembles the raw bytes between the quotes verbatim; JSON string escape + // sequences are only resolved here, so callers see fully decoded string values + return unescape(sink); + } + + private CharSequence unescape(CharSequence raw) { + final int n = raw.length(); + int i = 0; + while (i < n && raw.charAt(i) != '\\') { + i++; + } + if (i == n) { + return raw; // no escapes - the common case, return the assembled value unchanged + } + unescapeSink.clear(); + unescapeSink.put(raw, 0, i); + 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: drop the backslash, keep the following character + unescapeSink.put(esc); + i += 2; + } + break; + default: + // unknown escape: drop the backslash, keep the escaped character (lenient) + unescapeSink.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/line/http/AbstractLineHttpSender.java b/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java index 398aa70a1..3d028212e 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,6 +27,7 @@ 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; @@ -88,6 +89,7 @@ public abstract class AbstractLineHttpSender implements Sender { private boolean closed; private int currentAddressIndex; private long flushAfterNanos = Long.MAX_VALUE; + private HttpTokenProvider httpTokenProvider; private JsonErrorParser jsonErrorParser; private boolean lastFlushFailed; private long pendingRows; @@ -225,7 +227,8 @@ 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 ); } @@ -244,7 +247,8 @@ public static AbstractLineHttpSender createLineSender( int maxBackoffMillis, long minRequestThroughput, long flushIntervalNanos, - int protocolVersion + int protocolVersion, + HttpTokenProvider httpTokenProvider ) { HttpClient cli = null; Rnd rnd = new Rnd(NanosecondClockImpl.INSTANCE.getTicks(), MicrosecondClockImpl.INSTANCE.getTicks()); @@ -334,9 +338,10 @@ public static AbstractLineHttpSender createLineSender( 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 +360,9 @@ public static AbstractLineHttpSender createLineSender( currentAddressIndex, rnd ); + break; case PROTOCOL_VERSION_V2: - return new LineHttpSenderV2( + sender = new LineHttpSenderV2( hosts, ports, path, @@ -375,8 +381,9 @@ public static AbstractLineHttpSender createLineSender( currentAddressIndex, rnd ); + break; case PROTOCOL_VERSION_V3: - return new LineHttpSenderV3( + sender = new LineHttpSenderV3( hosts, ports, path, @@ -395,9 +402,22 @@ public static AbstractLineHttpSender createLineSender( currentAddressIndex, rnd ); + break; default: throw new LineSenderException("Unsupported protocol version: " + protocolVersion); } + if (httpTokenProvider != null) { + // wire the per-request token provider and rebuild the pending request so its first send + // already carries a provider-sourced token (the constructor built it before this was set) + sender.httpTokenProvider = httpTokenProvider; + try { + sender.request = sender.newRequest(); + } catch (Throwable t) { + Misc.free(sender); + throw t; + } + } + return sender; } public static boolean isNotFound(DirectUtf8Sequence statusCode) { @@ -733,6 +753,9 @@ private HttpClient.Request newRequest() { .header("User-Agent", "QuestDB/java/" + questDBVersion); if (username != null) { r.authBasic(username, password); + } else if (httpTokenProvider != null) { + // pull a fresh token per request so a long-lived sender follows token refreshes + r.authToken(httpTokenProvider.getToken()); } else if (authToken != null) { r.authToken(authToken); } 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 ed3c35c6b..368722f9e 100644 --- a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java +++ b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java @@ -237,4 +237,42 @@ public void testCategoryAndPolicyAreStillEnumerable() { Assert.assertNotNull(c); Assert.assertNotNull(p); } + + @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 testHttpTokenProviderRejectedForNonHttpTransport() { + // the provider is an HTTP-only feature + try { + Sender.builder(Sender.Transport.TCP).address("localhost:9009") + .httpTokenProvider(() -> "dynamic").build().close(); + Assert.fail("expected provider to be rejected for TCP"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("token provider authentication is not supported for TCP")); + } + } } 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..614439015 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java @@ -0,0 +1,266 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.Collections; +import java.util.List; + +/** + * 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 Handler handler; + 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()); + Thread acceptThread = new Thread(this::acceptLoop, "mock-oidc-accept"); + acceptThread.setDaemon(true); + acceptThread.start(); + } + + public static MockResponse chunkedJson(int status, String body) { + return new MockResponse(status, body, true); + } + + public static MockResponse json(int status, String body) { + return new MockResponse(status, body, false); + } + + public static MockResponse stall() { + MockResponse response = new MockResponse(200, "", true); + response.stall = true; + return response; + } + + @Override + public void close() throws IOException { + serverSocket.close(); + } + + 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 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 writeResponse(OutputStream out, MockResponse response) throws IOException { + 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; + } + 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(); + Thread connThread = new Thread(() -> handleConnection(socket), "mock-oidc-conn"); + connThread.setDaemon(true); + 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); + writeResponse(out, handler.handle(request.method, request.path, request.body)); + } + } 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 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/OidcDeviceAuthTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java new file mode 100644 index 000000000..f3f82e57f --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java @@ -0,0 +1,1692 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.json.JsonLexer; +import io.questdb.client.cutlass.json.JsonParser; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.std.str.StringSink; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +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 static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +public class OidcDeviceAuthTest { + + 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"; + + @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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("the user declined")); + } + } + }); + } + + @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.getToken()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb")); + } + }); + } + + @Test(timeout = 30_000) + public void testBuilderRejectsMissingRequiredOptions() { + try { + 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.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.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 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.getToken()); + 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 testChunkedTokenResponseParses() throws Exception { + assertMemoryLeak(() -> { + // real IdPs use Transfer-Encoding: chunked; a multi-KB id token split across chunks must parse + StringBuilder bigToken = new StringBuilder(); + for (int i = 0; i < 3000; i++) { + bigToken.append('a'); + } + String idToken = bigToken.toString(); + 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.getToken()); + } + }); + } + + @Test(timeout = 30_000) + public void testClearCacheForcesFreshSignIn() throws Exception { + assertMemoryLeak(() -> { + // clearCache() must drop the cached token AND the refresh token, so the next getToken() 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.getToken()); + 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.getToken()); + 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 testClockSkewSecondsForcesEarlyRefresh() throws Exception { + assertMemoryLeak(() -> { + // a clock skew larger than the token lifetime makes a freshly-issued token count as already + // expired, so the second getToken() refreshes instead of returning the cached 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", 60)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .clockSkewSeconds(120) // larger than the 60s token lifetime + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + Assert.assertEquals("ACCESS-1", auth.getToken()); + // the 60s token sits within the 120s skew, so it is treated as expired and refreshed + Assert.assertEquals("ACCESS-2", auth.getToken()); + 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 getToken() 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.getToken(); + outcome.set(new AssertionError("getToken() 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("getToken() 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 testConcurrentGetTokenStartsSingleSignIn() throws Exception { + assertMemoryLeak(() -> { + // several callers race getToken() 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.getToken(); + } catch (Throwable t) { + error.set(t); + } + }, "oidc-getToken-" + 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 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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("invalid_client", e.getOauthError()); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unknown client")); + } + } + }); + } + + @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.getToken()); + 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 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(""), true)) { + Assert.assertEquals("ACCESS-SCOPE", auth.getToken()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); + Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("groups")); + } + } + }); + } + + @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(""), true)) { + // enabled stayed true (no DoS), groups-in-token stayed false (access token served), + // scope stayed "openid" (no injection) + Assert.assertEquals("ACCESS-TRUSTED", auth.getToken()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); + Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("INJECTED")); + } + } + }); + } + + @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.fromQuestDB(server.httpUrl(""), true); + 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.fromQuestDB(server.httpUrl(""), true); + Assert.fail("expected discovery to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Exception { + // discoverSettings allocates a JSON lexer and an HTTP client and frees both in a finally; a transport + // failure during discovery must not leak the lexer's native buffer. The module's assertMemoryLeak does + // not flag single-tag growth, so measure the parser tag directly (as testMalformedEndpoint... does). + 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); + try { + OidcDeviceAuth.fromQuestDB("http://127.0.0.1:" + deadPort, true); + 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)); + } + + @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.getToken()); + // 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 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"); + } + + @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.getToken()); + // 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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + // the escapes are decoded, not shown literally + Assert.assertTrue(e.getMessage(), e.getMessage().contains("it\"s a / test")); + 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.getToken()); + 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 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(""), true)) { + // discovery advertises groups.encoded.in.token=true, so getToken() must return the id token + Assert.assertEquals("ID-D", auth.getToken()); + } + } + }); + } + + @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.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.fromQuestDB(server.httpUrl(""), true); + 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.fromQuestDB(server.httpUrl(""), true); + 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 getToken() + 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.getToken()); + // the cached token is expired vs the 30s skew, 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.getToken()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { + assertMemoryLeak(() -> { + // getTokenSilently() 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, getTokenSilently() must not prompt - it throws + try { + auth.getTokenSilently(); + Assert.fail("expected getTokenSilently() to fail before sign-in"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no token")); + } + // sign in once interactively + Assert.assertEquals("ACCESS-1", auth.getToken()); + // the cached token is expired vs the 30s skew, so getTokenSilently() refreshes silently + Assert.assertEquals("ACCESS-2", auth.getTokenSilently()); + // now make the refresh fail; getTokenSilently() must throw, not start the device flow + refreshOk.set(false); + try { + auth.getTokenSilently(); + Assert.fail("expected getTokenSilently() to fail when the refresh is rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("interactive sign-in")); + } + // the device flow ran exactly once (the initial getToken), and the user was prompted once + Assert.assertEquals(1, deviceCalls.get()); + Assert.assertEquals(1, promptCalls.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); getToken() 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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("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.getToken()); + } + }); + } + + @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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete device authorization")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testInsecureEndpointsRejectedUnlessOptedIn() throws Exception { + assertMemoryLeak(() -> { + // http endpoints carry tokens in cleartext; the client must refuse them unless the caller opts in + try { + 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.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")); + } + // opting in allows http, for local development + OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("http://idp.example/device") + .tokenEndpoint("http://idp.example/token") + .allowInsecureTransport(true) + .build() + .close(); + }); + } + + @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". + StringBuilder value = new StringBuilder(); + for (int i = 0; i < 4000; i++) { + value.append('a'); + } + String json = "{\"id_token\":\"" + value + "\"}"; + 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, 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(1024, 1 << 20, address, split, len); + } finally { + Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + @Test(timeout = 30_000) + public void testMalformedEndpointDoesNotLeakNativeMemory() { + // allowInsecureTransport skips build()'s own Endpoint.parse, so the constructor is the first to + // parse and throw on this malformed url; the native JSON lexer must not have been allocated yet + // (otherwise the never-returned instance leaks it). Measure the parser tag directly - the + // module's assertMemoryLeak does not flag a single-tag growth. + long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); + try { + OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("not-a-url") + .tokenEndpoint("https://idp.example/token") + .allowInsecureTransport(true) + .build(); + Assert.fail("expected Endpoint.parse to reject the malformed url"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("expected a scheme")); + } + Assert.assertEquals("the JSON lexer native buffer leaked", + 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; getToken() 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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no access_token")); + } + } + }); + } + + @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.getToken()); + } + }); + } + + @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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + String msg = e.getMessage(); + assertNoControlChars(msg); + Assert.assertTrue(msg, msg.contains("access_denied")); + 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.getToken()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + // the absurd interval/expires_in are clamped to the documented maxima + Assert.assertTrue("interval=" + challenge.getIntervalSeconds(), challenge.getIntervalSeconds() <= 300); + Assert.assertTrue("expiresIn=" + challenge.getExpiresInSeconds(), challenge.getExpiresInSeconds() <= 3600); + } + }); + } + + @Test(timeout = 30_000) + public void testPersistentTransportFailureDuringPollingAborts() throws Exception { + assertMemoryLeak(() -> { + // the device endpoint works, but the token endpoint is unreachable; polling must abort with + // the underlying transport error after a few attempts, not retry silently until the code expires + int deadPort; + try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + deadPort = probe.getLocalPort(); + } // closed now - nothing listens on deadPort + MockOidcServer.Handler handler = (method, path, body) -> + MockOidcServer.json(200, deviceAuthorizationJson(1, 10)); + try (MockOidcServer server = new MockOidcServer(handler)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint("http://127.0.0.1:" + deadPort + "/token") + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + auth.getToken(); + Assert.fail("expected a transport failure to abort polling"); + } catch (OidcAuthException e) { + // surfaces the transport failure, not the device-code-expired timeout + Assert.assertFalse(e.getMessage(), e.getMessage().contains("timed out")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unreachable")); + } + } + }); + } + + @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.getToken()); + // the refresh is rejected, so the flow re-runs the interactive sign-in + Assert.assertEquals("ACCESS-2", auth.getToken()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 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.getToken()); + // first refresh omits refresh_token, so REFRESH-1 must be kept + Assert.assertEquals("ACCESS-R1", auth.getToken()); + // second refresh must still present the retained REFRESH-1 (asserted in the handler) + Assert.assertEquals("ACCESS-R2", auth.getToken()); + Assert.assertEquals("no extra interactive sign-in", 1, deviceCalls.get()); + Assert.assertEquals(2, refreshCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Exception { + assertMemoryLeak(() -> { + // groups are encoded in the token (the default enterprise config), so getToken() 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.getToken()); + // 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.getToken()); + 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.getToken()); + 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.getToken()); + // the cached token is expired vs the 30s skew, so the second call refreshes silently + Assert.assertEquals("ACCESS-2", auth.getToken()); + 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.getToken()); + 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.getToken(); + Assert.fail("expected the stalled body read to abort"); + } catch (OidcAuthException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + // aborted on the ~1s OIDC timeout, not the 600s HttpClient default (or an indefinite wedge) + Assert.assertTrue("aborted too slowly: " + elapsedMillis + "ms", elapsedMillis < 10_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())) { + try { + auth.getToken(); + Assert.fail("expected a timeout"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } + }); + } + + @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.getToken()); + Assert.assertEquals("ACCESS-C", auth.getToken()); + Assert.assertEquals("ACCESS-C", auth.getToken()); + 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 200 that carries a token but is malformed JSON: the parser fails, and the raw body + // (with the token) must NOT be echoed into the exception message + return MockOidcServer.json(200, "{\"access_token\":\"" + secret + "\" not-valid-json}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertFalse("the token must not leak into the message: " + e.getMessage(), + e.getMessage().contains(secret)); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpStatus=")); + } + } + }); + } + + @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.getToken()); + 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.fromQuestDB(server.httpUrl(""), true); + 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 + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, "{\"access_token\":\"abc"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testUnreachableDeviceEndpointThrowsOidcAuthException() throws Exception { + assertMemoryLeak(() -> { + // a connection failure to the device endpoint must surface as OidcAuthException (getToken'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.getToken(); + 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 getToken()/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); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/device") + .tokenEndpoint("https://idp.example/token") + .build(); + auth.close(); + try { + auth.getToken(); + Assert.fail("expected getToken() after close() to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); + } + try { + auth.clearCache(); + Assert.fail("expected clearCache() after close() to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); + } + // getToken() 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.getToken()); + 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). getToken() 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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException on the first call"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); + } + // the unusable grant must NOT be cached as valid: the next call re-runs the flow and succeeds + Assert.assertEquals("ID-2", auth.getToken()); + 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.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)); + } + } + + 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 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 + + "}"; + } + + 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 cacheSize, int cacheSizeLimit, long address, int split, int len) throws JsonException { + try (JsonLexer lexer = new JsonLexer(cacheSize, cacheSizeLimit)) { + lexer.parse(address, address + split, NOOP_JSON_PARSER); + lexer.parse(address + split, address + len, NOOP_JSON_PARSER); + lexer.parseLast(); + } + } + + 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(); + } +} 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..2c67bb81a 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 @@ -32,6 +32,7 @@ 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 +244,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 +666,38 @@ 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\":\"tab\\tend\"}", "tab\tend"); // escaped tab -> tab + assertDecodedValue("{\"v\":\"plain\"}", "plain"); // no escapes (fast path) + }); + } + + 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/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java new file mode 100644 index 000000000..5e6adece4 --- /dev/null +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -0,0 +1,44 @@ +package com.example.sender; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.OidcDeviceAuth; + +/** + * Signs in to an OIDC-secured QuestDB Enterprise from code that has no local browser + * (a remote notebook kernel, a container, a headless job) using the OAuth 2.0 Device + * Authorization Grant, then shows the three ways to use the resulting token. + *

+ * On first use this prints a verification URL and a short code; open the URL in any + * browser (your laptop or your phone) and enter the code. The token is then cached in + * memory and refreshed silently, so re-running this does not prompt again. + */ +public class OidcDeviceFlowExample { + public static void main(String[] args) { + // Discover client id, scope, endpoints and the groups-in-token mode from the server. + // Alternatively, configure the identity provider explicitly with OidcDeviceAuth.builder(). + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { + auth.getToken(); // sign in once (prompts on first use, then caches and refreshes silently) + + // 1. Ingest with the QuestDB client over ILP-over-HTTP, presenting the token as a Bearer. + // Pass a provider, not the fixed token, so a long-lived sender follows silent refreshes. + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("questdb.example.com:9000") + .enableTls() + .httpTokenProvider(auth::getTokenSilently) + .build()) { + sender.table("trades") + .symbol("symbol", "ETH-USD") + .doubleColumn("price", 2615.54) + .atNow(); + } + + // 2. Query the REST API directly: send the token in the Authorization header. + // String header = auth.getAuthorizationHeaderValue(); // "Bearer " + // GET https://questdb.example.com:9000/exec?query=... with header Authorization:

+ + // 3. Connect over PG-wire with any JDBC or psql client: user "_sso", password = the token + // (requires acl.oidc.pg.token.as.password.enabled=true on the server). + // jdbc:postgresql://questdb.example.com:8812/qdb user=_sso password= + } + } +} From c92ee564c49bc70aa72391713b0e80b224e375f2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 17 Jun 2026 19:37:20 +0100 Subject: [PATCH 002/192] Sanitize bidi in OIDC prompt, defer token pull Two fixes for the OIDC device flow in the Java client. M2 - Bidi / zero-width Unicode bypassed the display sanitizer. sanitizeForDisplay and OidcAuthException.putSanitized filtered only on Character.isISOControl, which covers C0/C1 and DEL but not the bidirectional overrides (U+202A-202E), isolates (U+2066-2069), marks (U+200E/200F), zero-width characters or the BOM (U+FEFF). Those fields - user_code, verification_uri(_complete), error and error_description - all come from the IdP/settings boundary and reach System.out and the exception messages, so a hostile or MITM'd IdP could embed a right-to-left override and spoof the verification URL a human reads and then opens. The JSON lexer's \uXXXX decoding widens the vector, since an escaped override decodes to the real character before display. Both sanitizers now share OidcAuthException.isUnsafeForDisplay, which also strips the Unicode format category (Cf) plus the explicit bidi/BOM set. The predicate uses hex int literals rather than char escapes, keeping the source strictly ASCII so the file carries none of the characters it guards against. M3 - httpTokenProvider forced a successful sign-in before build(). createLineSender eagerly rebuilt the pending request when a provider was set, calling getToken() at build time. With the documented .httpTokenProvider(auth::getTokenSilently), that threw unless the caller had already signed in, so the natural "construct the sender, sign in, then send" ordering was impossible. The first token pull is now deferred off the build path to the first row (table()). The provider is wired at build but not queried; the initial request is stamped with a token when the first row starts, and the pending flag is cleared only after the pull succeeds, so a not-yet-signed-in provider that throws leaves the stamp pending for a retry. The Sender.httpTokenProvider Javadoc now states the provider is not called at build time. Tests: new bidi/zero-width cases for the challenge fields and the oauth error message (fed as JSON \uXXXX escapes so they exercise the decode-then-display path), and a new LineHttpSenderTokenProviderTest covering the deferred pull and a lazily signing-in provider. Each test was confirmed to fail without its fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 10 +- .../cutlass/auth/OidcAuthException.java | 23 +++- .../client/cutlass/auth/OidcDeviceAuth.java | 20 ++-- .../line/http/AbstractLineHttpSender.java | 30 +++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 88 +++++++++++++++ .../line/LineHttpSenderTokenProviderTest.java | 104 ++++++++++++++++++ 6 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index dc2297665..eeac08208 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2015,9 +2015,13 @@ public LineSenderBuilder httpToken(String token) { * long-lived sender following token refreshes - for example a token obtained through the OIDC * device flow: {@code .httpTokenProvider(auth::getTokenSilently)}. *
- * The provider runs on the flush path, so it must return promptly and must not block on - * interactive input (see {@link HttpTokenProvider}). Only valid for HTTP transport, and mutually - * exclusive with {@link #httpToken(String)} and {@link #httpUsernamePassword(String, String)}. + * The sender does not call the provider at build time: the first call happens when the first row + * is started, then once per flush. A provider that signs in lazily can therefore be wired before + * the interactive sign-in completes, as long as a token is obtainable before the first row is + * added - otherwise that first row fails. The provider runs on the flush path, so it must return + * promptly and must not block on interactive input (see {@link HttpTokenProvider}). Only valid for + * HTTP transport, and mutually exclusive with {@link #httpToken(String)} and + * {@link #httpUsernamePassword(String, String)}. * * @param httpTokenProvider supplies the current HTTP authentication token * @return this instance for method chaining 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 index d10d7001e..82681cd24 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -67,6 +67,22 @@ public static OidcAuthException oauthError(CharSequence error, CharSequence desc return e; } + // Reports characters that must never reach a terminal or a log line. Beyond the C0/C1 controls and + // DEL that isISOControl covers, this strips the Unicode "format" category (Cf) - zero-width joiners, + // the byte-order mark, and the bidirectional embedding/override/isolate controls - plus an explicit + // bidi/BOM set, so an attacker-influenced value (a verification_uri, a user_code, an error string) + // carrying a right-to-left override cannot reorder the text a human reads, even on a JDK whose + // Unicode tables categorize these differently. Hex literals (not char escapes) keep this source + // strictly ASCII, so the file itself carries none of the characters it guards against. + static boolean isUnsafeForDisplay(char c) { + return Character.isISOControl(c) + || Character.getType(c) == Character.FORMAT + || (c >= 0x202A && c <= 0x202E) // LRE, RLE, PDF, LRO, RLO + || (c >= 0x2066 && c <= 0x2069) // LRI, RLI, FSI, PDI + || c == 0x200E || c == 0x200F // LRM, RLM + || c == 0xFEFF; // BOM / zero-width no-break space + } + @Override public String getMessage() { return message.toString(); @@ -91,13 +107,14 @@ public OidcAuthException put(long value) { return this; } - // appends untrusted text with control characters stripped, so an attacker-influenced IdP error - // string cannot inject ANSI escapes or forge log lines when the exception message is rendered + // appends untrusted text with display-unsafe characters 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; i++) { char c = cs.charAt(i); - if (!Character.isISOControl(c)) { + if (!isUnsafeForDisplay(c)) { message.put(c); } } 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 index ae4acefad..a8f0a6e1a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -466,25 +466,27 @@ private static String sanitizeForDisplay(String value) { if (value == null) { return null; } - int firstControl = -1; + int firstUnsafe = -1; int n = value.length(); for (int i = 0; i < n; i++) { - if (Character.isISOControl(value.charAt(i))) { - firstControl = i; + if (OidcAuthException.isUnsafeForDisplay(value.charAt(i))) { + firstUnsafe = i; break; } } - if (firstControl < 0) { + if (firstUnsafe < 0) { // common case: nothing to strip return value; } - // an attacker-influenced device-auth field smuggled in control characters (ANSI escapes, - // CR/LF); strip them so a prompt cannot be tricked into rewriting or spoofing the terminal + // an attacker-influenced device-auth field smuggled in characters that can rewrite or spoof the + // terminal - ANSI escapes, CR/LF, or bidi/zero-width formatting that reorders or hides text - so + // strip them; otherwise 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, firstControl); - for (int i = firstControl + 1; i < n; i++) { + sink.put(value, 0, firstUnsafe); + for (int i = firstUnsafe + 1; i < n; i++) { char c = value.charAt(i); - if (!Character.isISOControl(c)) { + if (!OidcAuthException.isUnsafeForDisplay(c)) { sink.put(c); } } 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 3d028212e..57b766302 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 @@ -90,6 +90,7 @@ public abstract class AbstractLineHttpSender implements Sender { private int currentAddressIndex; private long flushAfterNanos = Long.MAX_VALUE; private HttpTokenProvider httpTokenProvider; + private boolean isInitialTokenPending; private JsonErrorParser jsonErrorParser; private boolean lastFlushFailed; private long pendingRows; @@ -407,15 +408,13 @@ public static AbstractLineHttpSender createLineSender( throw new LineSenderException("Unsupported protocol version: " + protocolVersion); } if (httpTokenProvider != null) { - // wire the per-request token provider and rebuild the pending request so its first send - // already carries a provider-sourced token (the constructor built it before this was set) + // wire the per-request token provider. The constructor built the initial request before the + // provider was set, so it carries no token yet; defer pulling the first token off the build + // path to the first row (table()), instead of calling getToken() here. That lets a provider + // that signs in lazily - e.g. OidcDeviceAuth::getTokenSilently - be wired before the sign-in + // has completed, and keeps the token pull on the use/flush path the provider documents sender.httpTokenProvider = httpTokenProvider; - try { - sender.request = sender.newRequest(); - } catch (Throwable t) { - Misc.free(sender); - throw t; - } + sender.isInitialTokenPending = true; } return sender; } @@ -559,6 +558,9 @@ public Sender table(CharSequence table) { if (table.length() == 0) { throw new LineSenderException("table name cannot be empty"); } + // pull the deferred provider token (if any) before writing the first row, so the first send + // carries it; a no-op once the token has been stamped or when no provider is configured + stampInitialTokenIfPending(); // set bookmark at start of the line. rowBookmark = request.getContentLength(); state = RequestState.TABLE_NAME_SET; @@ -789,6 +791,18 @@ private boolean rowAdded() { return pendingRows == autoFlushRows; } + private void stampInitialTokenIfPending() { + if (isInitialTokenPending) { + // the build path deferred the first provider token so a provider that signs in lazily (e.g. + // OidcDeviceAuth::getTokenSilently) could be wired before sign-in completed. The caller is now + // starting the first row, so pull the token and rebuild the still-empty initial request to + // carry it before any row data goes in. Clear the flag only after newRequest() succeeds, so a + // pull that throws because the caller has not signed in yet leaves the stamp pending for a retry + request = newRequest(); + isInitialTokenPending = false; + } + } + private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable) { CharSequence statusAscii = statusCode.asAsciiCharSequence(); if (Chars.equals("405", statusAscii)) { 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 index f3f82e57f..bf360f5ac 100644 --- 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 @@ -129,6 +129,47 @@ public void testBuilderRejectsMissingRequiredOptions() { } } + @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.getToken()); + 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(() -> { @@ -1037,6 +1078,31 @@ public void testNullPromptDefaultsToSystemOut() throws Exception { }); } + @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())) { + try { + auth.getToken(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + String msg = e.getMessage(); + assertNoUnsafeDisplayChars(msg); + Assert.assertTrue(msg, msg.contains("access_denied")); + Assert.assertTrue(msg, msg.contains("deniedreversedend")); // readable text survives, controls gone + } + } + }); + } + @Test(timeout = 30_000) public void testOauthErrorMessageStripsControlChars() throws Exception { assertMemoryLeak(() -> { @@ -1623,6 +1689,20 @@ private static void assertNoControlChars(String value) { } } + private static void assertNoUnsafeDisplayChars(String value) { + // mirrors OidcAuthException.isUnsafeForDisplay: no controls, no Cf format chars, no bidi/BOM + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean unsafe = Character.isISOControl(c) + || Character.getType(c) == Character.FORMAT + || (c >= 0x202A && c <= 0x202E) + || (c >= 0x2066 && c <= 0x2069) + || c == 0x200E || c == 0x200F + || c == 0xFEFF; + Assert.assertFalse("display-unsafe char U+" + Integer.toHexString(c) + " at index " + i + " in '" + value + "'", unsafe); + } + } + private static String deviceAuthorizationJson(int interval, int expiresIn) { return "{" + "\"device_code\":\"DEV-CODE\"," @@ -1634,6 +1714,14 @@ private static String deviceAuthorizationJson(int interval, int expiresIn) { + "}"; } + // 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; + } + private static OidcDeviceAuth newAuth(MockOidcServer server, boolean groupsInToken, DeviceCodePrompt prompt) { return OidcDeviceAuth.builder() .clientId("questdb") 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..092f8fef0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java @@ -0,0 +1,104 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.cutlass.line.LineSenderException; +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 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::getTokenSilently)} - be wired before the interactive sign-in + * has completed. + *

+ * An explicit {@code protocol_version} keeps {@link Sender.LineSenderBuilder#build()} from probing + * the server, and auto-flush is disabled, so rows can be buffered against a port nobody listens on + * without ever opening a connection. + */ +public class LineHttpSenderTokenProviderTest { + + @Test + public void testBuildSucceedsWhenProviderHasNotSignedInYet() { + // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getTokenSilently + 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 + public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() { + 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()); + } + } +} From c3a4749aab8b6e1b26367729bec27e8f3ab8fc1f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 17 Jun 2026 19:54:27 +0100 Subject: [PATCH 003/192] Harden OIDC parser: null, port range, token TTL Three robustness fixes in the OIDC device-flow parser. m3 - A JSON null arrives from the lexer as the literal "null". The token and device parsers used putValue, which stored it verbatim, so "access_token": null became the 4-char token "null" and "error": null was read as an OAuth error code "null". Merged putValue with SettingsDiscoveryParser's null-guarding putNonNull into one shared helper used by all three parsers, so a JSON null is treated as absent everywhere. m4 - Endpoint.parse did not range-check the port, so host:0, host:-1 and host:99999 parsed and flowed to the transport. Added a 1..65535 guard that rejects them with a clear message. m5 - The token-response expires_in was not clamped, unlike the device-auth value, so a TTL near Integer.MAX_VALUE cached the token for ~68 years. storeTokens now applies the same boundedSeconds clamp (the default for a non-positive value, capped at MAX_EXPIRES_IN_SECONDS). The server still enforces the real expiry; this only bounds how long the client trusts its cached copy. Tests: null access_token and null error are rejected/ignored, out-of-range ports are rejected at build, and a clamped token expiry forces a fresh sign-in (observed via a clock-skew margin set above the clamp). Each test was confirmed to fail without its fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 48 +++++----- .../test/cutlass/auth/OidcDeviceAuthTest.java | 91 +++++++++++++++++++ 2 files changed, 115 insertions(+), 24 deletions(-) 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 index a8f0a6e1a..802b423b8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -447,11 +447,14 @@ private static int parseIntOrZero(CharSequence value) { } } - private static void putValue(StringSink sink, CharSequence tag) { + private static void putNonNull(StringSink sink, CharSequence tag) { // clear before storing so a repeated key in the response replaces, rather than concatenates onto, - // the previous value (the same clear-before-put guard SettingsDiscoveryParser.putNonNull applies) + // 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(); - sink.put(tag); + if (!Chars.equals("null", tag)) { + sink.put(tag); + } } private static void requireSecureTransport(boolean isTls, String label, String url) { @@ -710,7 +713,10 @@ private void storeTokens(TokenResponseParser parser) { if (parser.refreshToken.length() > 0) { refreshToken = parser.refreshToken.toString(); } - int ttlSeconds = parser.expiresIn > 0 ? parser.expiresIn : DEFAULT_TOKEN_TTL_SECONDS; + // clamp like the device-side expires_in: fall back to the default for a non-positive value and 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); expiresAtMillis = System.currentTimeMillis() + ttlSeconds * 1000L; } @@ -950,16 +956,16 @@ public void onEvent(int code, CharSequence tag, int position) { if (depth == 1) { switch (field) { case FIELD_DEVICE_CODE: - putValue(deviceCode, tag); + putNonNull(deviceCode, tag); break; case FIELD_USER_CODE: - putValue(userCode, tag); + putNonNull(userCode, tag); break; case FIELD_VERIFICATION_URI: - putValue(verificationUri, tag); + putNonNull(verificationUri, tag); break; case FIELD_VERIFICATION_URI_COMPLETE: - putValue(verificationUriComplete, tag); + putNonNull(verificationUriComplete, tag); break; case FIELD_EXPIRES_IN: expiresIn = parseIntOrZero(tag); @@ -968,10 +974,10 @@ public void onEvent(int code, CharSequence tag, int position) { interval = parseIntOrZero(tag); break; case FIELD_ERROR: - putValue(error, tag); + putNonNull(error, tag); break; case FIELD_ERROR_DESCRIPTION: - putValue(errorDescription, tag); + putNonNull(errorDescription, tag); break; default: break; @@ -1033,6 +1039,9 @@ static Endpoint parse(String url) { } 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; @@ -1136,15 +1145,6 @@ public void onEvent(int code, CharSequence tag, int position) { break; } } - - private static void putNonNull(StringSink sink, CharSequence tag) { - // a JSON null is delivered as the literal "null", treat it as absent; clear first so a - // duplicate key cannot concatenate onto an earlier value - sink.clear(); - if (!Chars.equals("null", tag)) { - sink.put(tag); - } - } } private static final class TokenResponseParser implements JsonParser, Mutable { @@ -1208,22 +1208,22 @@ public void onEvent(int code, CharSequence tag, int position) { if (depth == 1) { switch (field) { case FIELD_ACCESS_TOKEN: - putValue(accessToken, tag); + putNonNull(accessToken, tag); break; case FIELD_ID_TOKEN: - putValue(idToken, tag); + putNonNull(idToken, tag); break; case FIELD_REFRESH_TOKEN: - putValue(refreshToken, tag); + putNonNull(refreshToken, tag); break; case FIELD_EXPIRES_IN: expiresIn = parseIntOrZero(tag); break; case FIELD_ERROR: - putValue(error, tag); + putNonNull(error, tag); break; case FIELD_ERROR_DESCRIPTION: - putValue(errorDescription, tag); + putNonNull(errorDescription, tag); break; default: break; 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 index bf360f5ac..ab0b28b5a 100644 --- 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 @@ -612,6 +612,11 @@ public void testEndpointParseRejectsMalformedUrls() { 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"); + // 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"); } @Test(timeout = 30_000) @@ -1054,6 +1059,55 @@ public void testNoAccessTokenWhenGroupsDisabledFails() throws Exception { }); } + @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())) { + try { + String token = auth.getToken(); + Assert.fail("a JSON null access_token must not be served as the literal token \"null\" [got=" + token + "]"); + } catch (OidcAuthException e) { + // null is absent, so a 2xx with no token is a definitive but malformed answer + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response")); + } + } + }); + } + + @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.getToken()); + } + }); + } + @Test(timeout = 30_000) public void testNullPromptDefaultsToSystemOut() throws Exception { assertMemoryLeak(() -> { @@ -1464,6 +1518,43 @@ public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception }); } + @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 like + // the device-side value, so the client does not trust a stale cached token for decades. With the + // clock-skew margin set above the clamp, a clamped token reads as already-expired on the next + // call and getToken() re-runs the flow; an unclamped ~68-year cache would be served instead, so + // the device endpoint would be hit only once. + 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) + .clockSkewSeconds(7200) // 2h, above the 1h (MAX_EXPIRES_IN_SECONDS) clamp + .build()) { + Assert.assertEquals("ACCESS-OK", auth.getToken()); + Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get()); + // the clamped 1h TTL minus the 2h skew is already in the past, so the next call re-runs the + // flow; without the clamp the ~68-year cache would be served and the flow would not run again + Assert.assertEquals("ACCESS-OK", auth.getToken()); + Assert.assertEquals("clamped token expiry forces a fresh sign-in", 2, deviceCalls.get()); + } + }); + } + @Test(timeout = 30_000) public void testTransientParseFailureDuringPollingRecovers() throws Exception { assertMemoryLeak(() -> { From 2a1ce7d07f85024ac74d11704a7edbc16c879cbc Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 14:17:52 +0100 Subject: [PATCH 004/192] fix test --- .../line/interop/ClientInteropTest.java | 68 +------------------ 1 file changed, 1 insertion(+), 67 deletions(-) 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)); From c036642e568cc73f921462945aa73a48873adb7d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 15:35:45 +0100 Subject: [PATCH 005/192] Fix sender corruption when the token provider throws The post-flush reset() eagerly rebuilt the next request and pulled the provider token via httpTokenProvider.getToken() after the current batch had already been sent and accepted. If that pull threw (e.g. OidcDeviceAuth::getTokenSilently when a silent refresh fails) it turned an already-successful flush into a thrown exception and left the shared Request half-built (contentStart == -1, no withContent()), so the next row's data went into the header region - a malformed request, lost rows and a permanently corrupted sender. Route every request's token pull through the same deferred, retriable path the initial request already used: newRequest() no longer pulls the provider token (it marks the request token-pending and builds a valid token-less request), and stampTokenIfPending() pulls it lazily when the first row of a request starts. A failed pull leaves the flag set and the sender untouched, so the next row re-runs the stamp and fully rebuilds the request. Per-request token rotation is unchanged. Rename isInitialTokenPending/stampInitialTokenIfPending to isTokenPending/stampTokenIfPending since the deferral now covers every request, and stamp the token in putRawMessage() too. Add a regression test that fails at the first, successful flush without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../line/http/AbstractLineHttpSender.java | 51 +++++++++++++------ .../test/cutlass/auth/OidcDeviceAuthTest.java | 51 +++++++++++++++++++ 2 files changed, 86 insertions(+), 16 deletions(-) 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 57b766302..f59d6044d 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 @@ -90,7 +90,7 @@ public abstract class AbstractLineHttpSender implements Sender { private int currentAddressIndex; private long flushAfterNanos = Long.MAX_VALUE; private HttpTokenProvider httpTokenProvider; - private boolean isInitialTokenPending; + private boolean isTokenPending; private JsonErrorParser jsonErrorParser; private boolean lastFlushFailed; private long pendingRows; @@ -414,7 +414,7 @@ public static AbstractLineHttpSender createLineSender( // that signs in lazily - e.g. OidcDeviceAuth::getTokenSilently - be wired before the sign-in // has completed, and keeps the token pull on the use/flush path the provider documents sender.httpTokenProvider = httpTokenProvider; - sender.isInitialTokenPending = true; + sender.isTokenPending = true; } return sender; } @@ -502,6 +502,9 @@ public Sender longColumn(CharSequence name, long value) { @TestOnly public void putRawMessage(Utf8Sequence msg) { + // pull the deferred provider token (if any) so a raw message sent as the first row of a request + // carries it, just like table() does; a no-op when no provider is configured + stampTokenIfPending(); request.put(msg); // message must include trailing \n state = RequestState.EMPTY; if (rowAdded()) { @@ -558,9 +561,9 @@ public Sender table(CharSequence table) { if (table.length() == 0) { throw new LineSenderException("table name cannot be empty"); } - // pull the deferred provider token (if any) before writing the first row, so the first send - // carries it; a no-op once the token has been stamped or when no provider is configured - stampInitialTokenIfPending(); + // pull the deferred provider token (if any) before writing 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; @@ -749,6 +752,10 @@ private void flush0(boolean closing) { } private HttpClient.Request newRequest() { + return newRequest(false); + } + + private HttpClient.Request newRequest(boolean pullProviderToken) { HttpClient.Request r = client.newRequest(currentHost(), currentPort()) .POST() .url(path) @@ -756,8 +763,18 @@ private HttpClient.Request newRequest() { if (username != null) { r.authBasic(username, password); } else if (httpTokenProvider != null) { - // pull a fresh token per request so a long-lived sender follows token refreshes - r.authToken(httpTokenProvider.getToken()); + if (pullProviderToken) { + // pull a fresh token per request so a long-lived sender follows token refreshes + r.authToken(httpTokenProvider.getToken()); + } else { + // do NOT pull the provider token on the construct/flush path: getToken() can throw (a + // provider that has not signed in yet, or a failed silent refresh), and pulling it here - + // after client.newRequest() has already reset and re-headered the shared request but + // before withContent() - would leave a half-built request behind and corrupt the sender, + // turning an already-successful flush into a thrown exception. Defer to the first row + // (stampTokenIfPending), where a failed pull is retriable and rebuilds the request cleanly + isTokenPending = true; + } } else if (authToken != null) { r.authToken(authToken); } @@ -791,15 +808,17 @@ private boolean rowAdded() { return pendingRows == autoFlushRows; } - private void stampInitialTokenIfPending() { - if (isInitialTokenPending) { - // the build path deferred the first provider token so a provider that signs in lazily (e.g. - // OidcDeviceAuth::getTokenSilently) could be wired before sign-in completed. The caller is now - // starting the first row, so pull the token and rebuild the still-empty initial request to - // carry it before any row data goes in. Clear the flag only after newRequest() succeeds, so a - // pull that throws because the caller has not signed in yet leaves the stamp pending for a retry - request = newRequest(); - isInitialTokenPending = false; + private void stampTokenIfPending() { + if (isTokenPending) { + // the construct/flush path deferred the provider token so a provider that signs in lazily (e.g. + // OidcDeviceAuth::getTokenSilently) 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 of + // this request, so pull the token and rebuild the still-empty request to carry it before any + // row data goes in. Clear the flag only after newRequest(true) succeeds, so a pull that throws + // (not signed in yet, or a failed refresh) leaves the stamp pending: the next row re-runs this + // and client.newRequest() fully rebuilds the request, so the sender is never left corrupted + request = newRequest(true); + isTokenPending = false; } } 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 index ab0b28b5a..8198dd2e3 100644 --- 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 @@ -901,6 +901,57 @@ public void testGroupsInTokenReturnsIdToken() throws Exception { }); } + @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::getTokenSilently 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 (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not be refreshed")); + } + + // 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(() -> { From eadc63f6733ad9bf0d5a02ba429489f9d81f4594 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 16:19:50 +0100 Subject: [PATCH 006/192] Stop getTokenSilently blocking the flush path getToken() and getTokenSilently() were both synchronized on the instance monitor, and getToken() holds it for the entire interactive device flow - up to the device-code lifetime, clamped to one hour. A long-lived Sender wired with httpTokenProvider(auth::getTokenSilently) therefore stalled on the flush path for up to an hour whenever another thread ran an interactive sign-in (e.g. a re-auth after the refresh token died). The javadoc claimed the opposite ("safe on a request/flush path"). Replace the synchronized methods with a ReentrantLock. getToken() and clearCache() still acquire it blocking, but getTokenSilently() now uses tryLock() and fails fast with an OidcAuthException instead of waiting: while a sign-in is in progress there is no token to serve anyway, so the caller gets a prompt, retriable exception rather than a wedged flush. The interactive flow still holds the lock for its whole duration and close() still sets the volatile cancellation flag before acquiring the lock, so the no-use-after-free guarantee is unchanged. Correct the class and getTokenSilently() javadocs, and add a regression test that fails (getTokenSilently blocks ~10s behind an in-flight sign-in) without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 140 +++++++++++------- .../test/cutlass/auth/OidcDeviceAuthTest.java | 48 ++++++ 2 files changed, 137 insertions(+), 51 deletions(-) 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 index 802b423b8..2c77a045b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -47,6 +47,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.concurrent.locks.ReentrantLock; /** * Obtains an OIDC access or id token using the OAuth 2.0 Device Authorization Grant @@ -80,13 +81,15 @@ * .build(); * } * {@link #getToken()} returns a cached token while it is still valid, silently refreshes it - * when a refresh token is available, and otherwise re-runs the interactive flow. The method - * is synchronized, so concurrent callers never start two sign-ins at once; the trade-off is - * that a sign-in waiting for the user holds the instance lock for the lifetime of the device - * code (up to an hour), and any other {@link #getToken()} or {@link #clearCache()} call on the - * same instance blocks behind it. To abort a sign-in that is waiting, call {@link #close()} - * from another thread: it cancels the in-flight flow, which then fails promptly with an - * {@link OidcAuthException} rather than running to the device-code timeout. + * when a refresh token is available, and otherwise re-runs the interactive flow. Calls are + * serialized on an instance lock, so concurrent callers never start two sign-ins at once. A + * sign-in waiting for the user holds that lock for the lifetime of the device code (up to an + * hour), so a concurrent {@link #getToken()} or {@link #clearCache()} call on the same instance + * blocks behind it - but {@link #getTokenSilently()} does not: it never waits for an in-flight + * sign-in, it fails fast with an {@link OidcAuthException}, so a request/flush path is never + * stalled. To abort a sign-in that is waiting, call {@link #close()} from another thread: it + * cancels the in-flight flow, which then fails promptly with an {@link OidcAuthException} rather + * than running to the device-code timeout. *

* Instances are interactive by design and hold a network connection; close them when done. * Token state lives in memory only and does not survive a restart of the process. @@ -137,6 +140,10 @@ public class OidcDeviceAuth implements QuietCloseable { private final StringSink formSink = new StringSink(); private final boolean groupsInToken; private final int httpTimeoutMillis; + // serializes getToken()/getTokenSilently()/clearCache()/close(); getToken() holds it for the whole + // interactive flow, getTokenSilently() acquires it without blocking (tryLock) so the flush path is + // never stalled behind an in-flight sign-in + private final ReentrantLock lock = new ReentrantLock(); private final DeviceCodePrompt prompt; private final StringSink responseStatus = new StringSink(); private final String scope; @@ -253,12 +260,17 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfigurati /** * Drops any cached token so the next {@link #getToken()} starts a fresh interactive sign-in. */ - public synchronized void clearCache() { - throwIfClosed(); - accessToken = null; - idToken = null; - refreshToken = null; - expiresAtMillis = 0; + public void clearCache() { + lock.lock(); + try { + throwIfClosed(); + accessToken = null; + idToken = null; + refreshToken = null; + expiresAtMillis = 0; + } finally { + lock.unlock(); + } } /** @@ -269,15 +281,18 @@ public synchronized void clearCache() { */ @Override public void close() { - // flag cancellation before taking the lock: getToken() holds the monitor for the whole - // interactive flow, so close() signals the in-flight sign-in to stop with a lock-free volatile - // write, then acquires the lock - which the now-cancelled flow releases promptly - and frees the - // native resources. close() never frees while a flow holds the lock, so there is no use-after-free + // flag cancellation before taking the lock: getToken() holds the lock for the whole interactive + // flow, so close() signals the in-flight sign-in to stop with a lock-free volatile write, then + // acquires the lock - which the now-cancelled flow releases promptly - and frees the native + // resources. close() never frees while a flow holds the lock, so there is no use-after-free closed = true; - synchronized (this) { + lock.lock(); + try { plainClient = Misc.free(plainClient); tlsClient = Misc.free(tlsClient); jsonLexer = Misc.free(jsonLexer); + } finally { + lock.unlock(); } } @@ -299,50 +314,73 @@ public String getAuthorizationHeaderValue() { * @throws OidcAuthException if the interactive flow fails, times out, or the identity provider * does not return the expected token */ - public synchronized String getToken() { - throwIfClosed(); - // only a cached copy of the token getToken() actually serves counts as a cache hit; a grant - // that returned the other kind (an access token when the server wants the id token, or vice - // versa) leaves the served token null, so the flow must re-run 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) { - if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { - return cachedToken; - } - if (refreshToken != null && tryRefresh()) { - return selectToken(); + public String getToken() { + lock.lock(); + try { + throwIfClosed(); + // only a cached copy of the token getToken() actually serves counts as a cache hit; a grant + // that returned the other kind (an access token when the server wants the id token, or vice + // versa) leaves the served token null, so the flow must re-run 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) { + if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } } + runDeviceFlow(); + return selectToken(); + } finally { + lock.unlock(); } - runDeviceFlow(); - return selectToken(); } /** - * Returns a valid token like {@link #getToken()} but never starts the interactive device flow: - * it returns the cached token while it is valid and silently refreshes it when a refresh token is - * available, otherwise it throws. Intended as a per-request token source for a long-lived client, - * for example {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, where an - * interactive prompt on the request path would be inappropriate. Call {@link #getToken()} once to - * sign in before handing this method to a client. + * Returns a valid token like {@link #getToken()} but never starts the interactive device flow and + * never blocks: it returns the cached token while it is valid and silently refreshes it when a + * refresh token is available, otherwise it throws. Designed for the request/flush path of a + * long-lived client, for example {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, + * where an interactive prompt would be inappropriate and a stalled flush unacceptable. Call + * {@link #getToken()} once to sign in before handing this method to a client. + *

+ * To keep the flush path responsive it returns promptly or throws promptly - it never waits for an + * interactive {@link #getToken()} in progress on another thread (which would otherwise stall the + * flush for the whole device-code lifetime). While such a sign-in runs there is no token to return + * anyway, so this method throws and the caller should retry once the sign-in completes. * * @return a non-null, non-empty token - * @throws OidcAuthException if no token has been obtained yet, or the cached token expired and - * could not be refreshed without an interactive sign-in + * @throws OidcAuthException if no token has been obtained yet, if the cached token expired and could + * not be refreshed without an interactive sign-in, or if a sign-in or + * refresh is already in progress on another thread */ - public synchronized String getTokenSilently() { + public String getTokenSilently() { throwIfClosed(); - final String cachedToken = groupsInToken ? idToken : accessToken; - if (cachedToken != null) { - if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { - return cachedToken; - } - if (refreshToken != null && tryRefresh()) { - return selectToken(); + // never wait on the flush path: getToken()'s interactive sign-in holds the lock for the whole + // device-code lifetime (up to an hour), so acquire it without blocking and fail fast if it is + // held. A sign-in in progress means there is no token to serve yet, so the caller gets a prompt + // exception to retry rather than a stalled flush + if (!lock.tryLock()) { + throw new OidcAuthException("a sign-in or token refresh is already in progress on another thread; no token is available without blocking - retry shortly"); + } + try { + throwIfClosed(); + final String cachedToken = groupsInToken ? idToken : accessToken; + if (cachedToken != null) { + if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } + throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call getToken() to sign in again"); } - throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call getToken() to sign in again"); + throw new OidcAuthException("no token has been obtained yet; call getToken() to sign in before using getTokenSilently()"); + } finally { + lock.unlock(); } - throw new OidcAuthException("no token has been obtained yet; call getToken() to sign in before using getTokenSilently()"); } private static String appendSettingsPath(String basePath) { 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 index 8198dd2e3..2badf5357 100644 --- 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 @@ -814,6 +814,54 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except }); } + @Test(timeout = 30_000) + public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exception { + assertMemoryLeak(() -> { + // an interactive getToken() is parked polling (authorization_pending), holding the instance + // lock for the whole device-code lifetime. A flush-path getTokenSilently() 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.getToken(); + } 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)); + // getTokenSilently() must return control promptly (here: throw), NOT block ~10s until + // the device code expires and getToken() releases the lock + long startNanos = System.nanoTime(); + try { + auth.getTokenSilently(); + Assert.fail("expected getTokenSilently() to fail fast while a sign-in is in progress"); + } catch (OidcAuthException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + Assert.assertTrue("getTokenSilently() blocked " + elapsedMillis + "ms behind the in-flight sign-in", + elapsedMillis < 2_000); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); + } + } 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 testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { assertMemoryLeak(() -> { From 58920aa430b9d1468ce68f55586bf4f535d8ca20 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 16:29:13 +0100 Subject: [PATCH 007/192] Sanitize display text per code point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isUnsafeForDisplay() inspected one UTF-16 code unit at a time, so a supplementary-plane (>= U+10000) format or control character - an invisible U+E00xx "tag" char, for instance - arrived as a surrogate pair whose halves are each neither a control nor category Cf and so passed the filter unstripped. Because the JSON lexer reassembles such 😀-style escapes, a hostile or man-in-the-middled identity provider could smuggle invisible/spoofing characters into a user_code, a verification_uri, or an error_description and on into the terminal prompt and exception messages. Judge a Unicode code point instead: isUnsafeForDisplay() takes an int, and both sanitizers (putSanitized for exception messages, sanitizeForDisplay for the prompt) walk the text by code point with Character.codePointAt/charCount, so Character.getType classifies a supplementary char as one character. A legitimate astral character (an emoji) is still preserved. Make the assertNoUnsafeDisplayChars test helper code-point-aware too - it shared the blind spot - and add a regression test that fails (the U+E0001 tag char survives) without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/auth/OidcAuthException.java | 29 +++++---- .../client/cutlass/auth/OidcDeviceAuth.java | 23 ++++--- .../test/cutlass/auth/OidcDeviceAuthTest.java | 62 ++++++++++++++++--- 3 files changed, 83 insertions(+), 31 deletions(-) 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 index 82681cd24..fa1314d4c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -67,14 +67,17 @@ public static OidcAuthException oauthError(CharSequence error, CharSequence desc return e; } - // Reports characters that must never reach a terminal or a log line. Beyond the C0/C1 controls and - // DEL that isISOControl covers, this strips the Unicode "format" category (Cf) - zero-width joiners, - // the byte-order mark, and the bidirectional embedding/override/isolate controls - plus an explicit - // bidi/BOM set, so an attacker-influenced value (a verification_uri, a user_code, an error string) - // carrying a right-to-left override cannot reorder the text a human reads, even on a JDK whose - // Unicode tables categorize these differently. Hex literals (not char escapes) keep this source - // strictly ASCII, so the file itself carries none of the characters it guards against. - static boolean isUnsafeForDisplay(char c) { + // Reports characters that must never reach a terminal or a log line. The parameter is a Unicode code + // point, not a UTF-16 unit, so a supplementary-plane (>= U+10000) format or control character - a + // surrogate pair the JSON lexer reassembled - is judged as one character rather than as two surrogate + // halves that each look harmless (the gap that let an invisible U+E00xx "tag" char slip through). + // Beyond the C0/C1 controls and DEL that isISOControl covers, this strips the Unicode "format" + // category (Cf) - zero-width joiners, the byte-order mark, the bidirectional embedding/override/isolate + // controls, and the U+E00xx tag characters - plus an explicit bidi/BOM set, so an attacker-influenced + // value (a verification_uri, a user_code, an error string) cannot reorder, hide, or spoof the text a + // human reads, even on a JDK whose Unicode tables categorize these differently. Hex literals (not char + // escapes) keep this source strictly ASCII, so the file itself carries none of the chars it guards against. + static boolean isUnsafeForDisplay(int c) { return Character.isISOControl(c) || Character.getType(c) == Character.FORMAT || (c >= 0x202A && c <= 0x202E) // LRE, RLE, PDF, LRO, RLO @@ -112,11 +115,13 @@ public OidcAuthException put(long value) { // when the exception message is rendered private void putSanitized(CharSequence cs) { if (cs != null) { - for (int i = 0, n = cs.length(); i < n; i++) { - char c = cs.charAt(i); - if (!isUnsafeForDisplay(c)) { - message.put(c); + 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 index 2c77a045b..38b38fc13 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -507,29 +507,34 @@ private static String sanitizeForDisplay(String value) { if (value == null) { return null; } + final int n = value.length(); int firstUnsafe = -1; - int n = value.length(); - for (int i = 0; i < n; i++) { - if (OidcAuthException.isUnsafeForDisplay(value.charAt(i))) { + 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) { // common case: nothing to strip return value; } // an attacker-influenced device-auth field smuggled in characters that can rewrite or spoof the - // terminal - ANSI escapes, CR/LF, or bidi/zero-width formatting that reorders or hides text - so - // strip them; otherwise a right-to-left override could make the verification URL a human reads + // terminal - ANSI escapes, CR/LF, or bidi/zero-width formatting (including supplementary-plane + // "tag" characters that arrive as surrogate pairs) that reorders or hides text - so strip them + // per code point; otherwise 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 + 1; i < n; i++) { - char c = value.charAt(i); - if (!OidcAuthException.isUnsafeForDisplay(c)) { - sink.put(c); + 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(); } 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 index 2badf5357..37561fa12 100644 --- 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 @@ -204,6 +204,46 @@ public void testChallengeStripsControlCharactersFromDisplayFields() throws Excep }); } + @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.getToken()); + 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(() -> { @@ -1880,16 +1920,18 @@ private static void assertNoControlChars(String value) { } private static void assertNoUnsafeDisplayChars(String value) { - // mirrors OidcAuthException.isUnsafeForDisplay: no controls, no Cf format chars, no bidi/BOM - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - boolean unsafe = Character.isISOControl(c) - || Character.getType(c) == Character.FORMAT - || (c >= 0x202A && c <= 0x202E) - || (c >= 0x2066 && c <= 0x2069) - || c == 0x200E || c == 0x200F - || c == 0xFEFF; - Assert.assertFalse("display-unsafe char U+" + Integer.toHexString(c) + " at index " + i + " in '" + value + "'", unsafe); + // 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 + || (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); } } From 1db99c1dc239471ad79e23d886f8546061a253ec Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 16:44:29 +0100 Subject: [PATCH 008/192] Reject tokens from error or non-2xx responses pollOnce() checked for a token before the HTTP status and the OAuth error field, so a response that carried a token alongside an error, or under a non-2xx status, was cached as a valid grant. tryRefresh() had the same flaw: it accepted the refreshed token on token presence alone. Both contradict RFC 6749 - 5.1 makes a grant a 2xx response carrying a token, and 5.2 says an error response must not be treated as a grant. Handle the OAuth error first in pollOnce(), so a token smuggled alongside an error never counts, and accept a token only when the status is 2xx; a token under a non-2xx status goes to the transport- error budget instead of being trusted. Guard tryRefresh() the same way: cache the refreshed token only from a clean 2xx response with no error, otherwise fall back to the interactive flow. The happy path and the existing pending/slow_down/access_denied/empty- body outcomes are unchanged. Add regression tests for a token alongside an error, a token under a non-2xx status, and a refresh that smuggles a token with an error - each fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 49 ++++++----- .../test/cutlass/auth/OidcDeviceAuthTest.java | 83 +++++++++++++++++++ 2 files changed, 112 insertions(+), 20 deletions(-) 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 index 38b38fc13..701117d7a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -576,26 +576,32 @@ private int pollOnce(String deviceCode) { // on a persistent failure rather than swallowing it as a pending authorization postForm(tokenEndpoint, tokenParser); - if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { - storeTokens(tokenParser); - return POLL_SUCCESS; + // RFC 6749 5.2: an error response is an error even if the body also carries a token, so handle the + // OAuth error first - 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); } - if (tokenParser.error.length() == 0) { - // a 2xx with neither tokens nor an OAuth error is a definitive but malformed answer and - // aborts; a non-2xx with no parseable error (a gateway 5xx, an empty body) is a transport- - // class blip - retry it rather than abort the whole sign-in on a momentary upstream failure + // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx status is a + // malformed or hostile answer - charge it to the transport-error budget rather than trusting it + if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { if (isHttpStatusSuccess()) { - throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); + storeTokens(tokenParser); + return POLL_SUCCESS; } return POLL_TRANSIENT_ERROR; } - if (Chars.equals(ERROR_AUTHORIZATION_PENDING, tokenParser.error)) { - return POLL_PENDING; - } - if (Chars.equals(ERROR_SLOW_DOWN, tokenParser.error)) { - return POLL_SLOW_DOWN; + // no tokens and no OAuth error: a 2xx is a definitive but malformed answer and aborts; a non-2xx + // (a gateway 5xx, an empty body) is a transport-class blip - retry rather than abort the sign-in + if (isHttpStatusSuccess()) { + throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); } - throw OidcAuthException.oauthError(tokenParser.error, tokenParser.errorDescription); + return POLL_TRANSIENT_ERROR; } private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) { @@ -793,13 +799,16 @@ private boolean tryRefresh() { } return false; } - // only treat the refresh as a success if it returned the token getToken() actually serves - // (the id token when groups are encoded in it, the access token otherwise); a refresh that - // omits the id token - which RFC 6749 permits and many providers do - must fall back to the - // interactive flow rather than fail later in selectToken() - boolean hasRequiredToken = groupsInToken + // only treat the refresh as a success if a clean 2xx response (no OAuth error) returned the token + // getToken() actually serves (the id token when groups are encoded in it, the access token + // otherwise). A refresh that omits the id token - which RFC 6749 permits and many providers do - + // or one that carries an error or arrives under a non-2xx status must fall back to the interactive + // flow rather than be cached (and later fail in selectToken()) + boolean hasRequiredToken = (groupsInToken ? tokenParser.idToken.length() > 0 - : tokenParser.accessToken.length() > 0; + : tokenParser.accessToken.length() > 0) + && isHttpStatusSuccess() + && tokenParser.error.length() == 0; if (hasRequiredToken) { storeTokens(tokenParser); return true; 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 index 37561fa12..07806d748 100644 --- 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 @@ -1440,6 +1440,39 @@ public void testRefreshKeepsExistingRefreshTokenWhenOmitted() throws Exception { }); } + @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.getToken()); + // the cached token is expired vs the skew; 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.getToken()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); + } + }); + } + @Test(timeout = 30_000) public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Exception { assertMemoryLeak(() -> { @@ -1607,6 +1640,31 @@ public void testTimesOutWhenCodeExpires() throws Exception { }); } + @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.getToken(); + 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(() -> { @@ -1694,6 +1752,31 @@ public void testTokenResponseExpiresInIsClamped() throws Exception { }); } + @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 - it charges the + // response to the transport-error budget and aborts 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())) { + try { + auth.getToken(); + Assert.fail("expected a token under a 400 to be rejected, not accepted"); + } catch (OidcAuthException e) { + Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("repeated unexpected responses")); + } + } + }); + } + @Test(timeout = 30_000) public void testTransientParseFailureDuringPollingRecovers() throws Exception { assertMemoryLeak(() -> { From c0ff593d5719e31929fc4898b0e2bd621160e2b9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 16:50:22 +0100 Subject: [PATCH 009/192] Reject a null or empty provider token newRequest() passed the token from httpTokenProvider.getToken() straight to authToken(), which does not null- or empty-check it. A provider that returned null, "", or whitespace therefore produced a malformed "Authorization: Bearer " header that the server only answered with a 401 far from the cause - no client-side error at all. The HttpTokenProvider contract forbids such a return but nothing enforced it, and httpToken() already rejects a blank token, so the provider path was the weaker spot. Validate the pulled token with Chars.isBlank (as httpToken does) and throw a clear LineSenderException instead. The check sits inside the deferred pull, so a rejected token leaves the stamp pending and the next row retries cleanly, just like a throwing provider does. OidcDeviceAuth never returns a blank token, so this guards custom providers. Add tests that a null, an empty, and a whitespace-only provider token is rejected at first use - each fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../line/http/AbstractLineHttpSender.java | 10 +++++-- .../line/LineHttpSenderTokenProviderTest.java | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) 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 f59d6044d..35841d2d8 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 @@ -764,8 +764,14 @@ private HttpClient.Request newRequest(boolean pullProviderToken) { r.authBasic(username, password); } else if (httpTokenProvider != null) { if (pullProviderToken) { - // pull a fresh token per request so a long-lived sender follows token refreshes - r.authToken(httpTokenProvider.getToken()); + // pull a fresh token per request so a long-lived sender follows token refreshes; reject a + // null/empty/blank return (the HttpTokenProvider contract forbids it) with a clear error + // rather than emit a malformed "Authorization: Bearer " header the server only 401s on + CharSequence token = httpTokenProvider.getToken(); + if (Chars.isBlank(token)) { + throw new LineSenderException("token provider returned a null or empty token"); + } + r.authToken(token); } else { // do NOT pull the provider token on the construct/flush path: getToken() can throw (a // provider that has not signed in yet, or a failed silent refresh), and pulling it here - 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 index 092f8fef0..8a0725da0 100644 --- 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 @@ -78,6 +78,16 @@ public void testBuildSucceedsWhenProviderHasNotSignedInYet() { } } + @Test + public void testNullOrEmptyProviderTokenIsRejected() { + // 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); + assertProviderTokenRejected(() -> ""); + assertProviderTokenRejected(() -> " "); + } + @Test public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() { AtomicInteger calls = new AtomicInteger(); @@ -101,4 +111,20 @@ public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() { Assert.assertEquals("provider must not be re-queried within the same batch", 1, calls.get()); } } + + private static void assertProviderTokenRejected(HttpTokenProvider provider) { + 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 a null or empty provider token to be rejected"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("null or empty token")); + } + } + } } From 9824d69f6621d50a031debad84188bffe9e34b56 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 17:18:01 +0100 Subject: [PATCH 010/192] improved tests --- .../test/cutlass/auth/MockOidcServer.java | 40 +++++++++++++++++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 11 +++-- 2 files changed, 45 insertions(+), 6 deletions(-) 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 index 614439015..41541ecec 100644 --- 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 @@ -47,6 +47,9 @@ * 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; private final List requestAuthHeaders = Collections.synchronizedList(new ArrayList<>()); private final ServerSocket serverSocket; @@ -54,9 +57,9 @@ public class MockOidcServer implements Closeable { public MockOidcServer(Handler handler) throws IOException { this.handler = handler; this.serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); - Thread acceptThread = new Thread(this::acceptLoop, "mock-oidc-accept"); - acceptThread.setDaemon(true); - acceptThread.start(); + this.acceptThread = new Thread(this::acceptLoop, "mock-oidc-accept"); + this.acceptThread.setDaemon(true); + this.acceptThread.start(); } public static MockResponse chunkedJson(int status, String body) { @@ -75,7 +78,27 @@ public static MockResponse stall() { @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); + } + } } public String httpUrl(String path) { @@ -90,6 +113,15 @@ 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; @@ -208,8 +240,10 @@ 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 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 index 07806d748..8b4d99477 100644 --- 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 @@ -597,14 +597,17 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { @Test(timeout = 30_000) public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Exception { - // discoverSettings allocates a JSON lexer and an HTTP client and frees both in a finally; a transport - // failure during discovery must not leak the lexer's native buffer. The module's assertMemoryLeak does - // not flag single-tag growth, so measure the parser tag directly (as testMalformedEndpoint... does). + // 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. + // The module's assertMemoryLeak does not reliably flag single-tag growth, so measure both tags + // directly. Measuring only the parser tag (as an earlier version did) was blind to a leak of the + // HTTP client's native buffers - the resource most likely to be left dangling on the failure path. 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.fromQuestDB("http://127.0.0.1:" + deadPort, true); Assert.fail("expected discovery to fail against a dead port"); @@ -613,6 +616,8 @@ public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Except } 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) From faa6e47c5a7444a8422afb0011457a81e22db54e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 17:48:57 +0100 Subject: [PATCH 011/192] Speed up JSON unescape and validate URL hosts JsonLexer.getCharSequence rescanned every decoded value and name from the start to look for a backslash, even though the parse loop already detects one when it sets ignoreNext. Record that in a sawEscape flag (carried across parse() fragments) and resolve escapes only when it is set, so the common no-escape value returns the assembled sink without a second pass. OidcDeviceAuth.Endpoint.parse now rejects a host that contains control characters or whitespace - a smuggled CR/LF would otherwise flow into the outbound Host header. Add the tests these paths lacked: a cross-fragment escape; the lexer's lenient and exotic escape arms (surrogate pairs, \b/\f, unknown and malformed escapes, lone surrogates); the version-probe settings parser reading an escaped key through unescape; HTTP-token-provider rejection for UDP and WebSocket (not just TCP); and the control-character host cases above. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 8 ++ .../client/cutlass/json/JsonLexer.java | 21 +++-- .../test/SenderBuilderErrorApiTest.java | 17 ++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 6 ++ .../test/cutlass/json/JsonLexerTest.java | 79 +++++++++++++++++++ 5 files changed, 119 insertions(+), 12 deletions(-) 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 index 701117d7a..692a35d5b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1101,6 +1101,14 @@ static Endpoint parse(String url) { if (host.isEmpty()) { throw new OidcAuthException().put("invalid url, the host is empty [url=").put(url).put(']'); } + for (int i = 0, n = host.length(); i < n; i++) { + char c = host.charAt(i); + if (c <= ' ' || c == 0x7f) { + // a host carrying control characters or whitespace (e.g. a smuggled CR/LF) would corrupt + // the outbound Host header, so reject it rather than pass it through to the transport + throw new OidcAuthException().put("invalid url, the host contains an illegal character [url=").put(url).put(']'); + } + } return new Endpoint(host, port, path, isTls); } } 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 528deb0ea..b2ba8d5ee 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 @@ -64,6 +64,7 @@ public class JsonLexer implements Mutable, Closeable { private int objDepth = 0; private int position = 0; private boolean quoted = false; + private boolean sawEscape = false; private int state = S_START; private boolean useCache = false; @@ -86,6 +87,7 @@ public void clear() { arrayDepth = 0; ignoreNext = false; quoted = false; + sawEscape = false; cacheSize = 0; useCache = false; position = 0; @@ -110,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 sawEscape = this.sawEscape; boolean useCache = this.useCache; int objDepth = this.objDepth; int arrayDepth = this.arrayDepth; @@ -126,6 +129,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { if (quoted) { if (c == '\\') { ignoreNext = true; + sawEscape = true; continue; } @@ -138,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, sawEscape), 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, sawEscape), vp); state = S_EXPECT_COMMA; } @@ -241,6 +245,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { } valueStart = p; quoted = true; + sawEscape = false; break; default: if (state != S_EXPECT_VALUE) { @@ -249,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; + sawEscape = false; break; } } @@ -258,6 +264,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { this.state = state; this.quoted = quoted; this.ignoreNext = ignoreNext; + this.sawEscape = sawEscape; this.objDepth = objDepth; this.arrayDepth = arrayDepth; @@ -332,7 +339,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)) { @@ -341,9 +348,11 @@ private CharSequence getCharSequence(long lo, long hi, int position) throws Json } else { utf8DecodeCacheAndBuffer(lo, hi - 1, position); } - // the decode above assembles the raw bytes between the quotes verbatim; JSON string escape - // sequences are only resolved here, so callers see fully decoded string values - return unescape(sink); + // the decode above assembled the raw bytes between the quotes verbatim; resolve JSON string escape + // sequences only when the scan actually saw a backslash. The common no-escape value (and every + // escape-free name) returns the assembled sink directly, instead of unescape() rescanning it from + // the start just to rediscover that there was nothing to unescape + return hasEscape ? unescape(sink) : sink; } private CharSequence unescape(CharSequence raw) { 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 368722f9e..3cd47996a 100644 --- a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java +++ b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java @@ -266,13 +266,18 @@ public void testHttpTokenProviderIsMutuallyExclusiveWithOtherAuth() { @Test public void testHttpTokenProviderRejectedForNonHttpTransport() { - // the provider is an HTTP-only feature - try { - Sender.builder(Sender.Transport.TCP).address("localhost:9009") - .httpTokenProvider(() -> "dynamic").build().close(); - Assert.fail("expected provider to be rejected for TCP"); + // the provider is an HTTP-only feature; every non-HTTP transport must reject it 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"); + assertProviderRejected(Sender.Transport.WEBSOCKET, "token provider authentication is not supported for WebSocket protocol"); + } + + 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("token provider authentication is not supported for TCP")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); } } } 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 index 8b4d99477..6c53f6394 100644 --- 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 @@ -662,6 +662,12 @@ public void testEndpointParseRejectsMalformedUrls() { 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 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"); } @Test(timeout = 30_000) 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 2c67bb81a..9e781b715 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,9 +24,11 @@ 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; @@ -679,6 +681,83 @@ public void testStringEscapesAreDecoded() throws Exception { }); } + @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 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 drops the backslash and keeps the following text rather than failing the parse. These pin + // that behavior and cover the lenient arms that otherwise carry most of the file's coverage: + assertDecodedValue("{\"v\":\"a" + bs + "xb\"}", "axb"); // unknown escape -> drop backslash + assertDecodedValue("{\"v\":\"a" + bs + "uZZZZb\"}", "auZZZZb"); // non-hex unicode escape -> literal + assertDecodedValue("{\"v\":\"ab" + bs + "u12\"}", "abu12"); // 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); From 19a996664544016bafb87cf3680ab7909ac60868 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 18 Jun 2026 18:38:41 +0100 Subject: [PATCH 012/192] Add OIDC issuer pin and .well-known discovery Port the issuer feature from py-questdb-client (PR #133) onto OidcDeviceAuth, so the device flow keeps working against servers that do not advertise their device-authorization endpoint, and so the device code and refresh token are only sent where the caller pins. The issuer plays three roles: - Discovery fallback: when /settings omits the device (and/or token) endpoint, fromQuestDB(url, issuer) reads it from the issuer's .well-known/openid-configuration document. The discovery origin comes only from the out-of-band issuer (or an explicit discoveryUrl), never from a /settings-supplied value, so a tampered /settings cannot redirect discovery. Without a pin, discovery is refused. - Plaintext-channel pin: a /settings response fetched over plaintext http to a non-loopback host (only reachable with allowInsecureTransport) cannot route credentials to its advertised endpoints without a pin. - Endpoint-origin pin: validateEndpointOrigins, enforced in Builder.build() on every construction path, requires the token and device endpoints to share one origin (RFC 8628 co-location) and, when an issuer is set, to belong to it. Config surface: Builder.issuer(...); new fromQuestDB overloads (url, issuer), (url, issuer, allowInsecure), and a 5-arg master taking issuer, discoveryUrl and a TLS config. Tradeoffs: - The co-location check makes the token and device endpoints share an origin. testPersistentTransportFailureDuringPollingAborts simulated an unreachable token endpoint with a dead second port; it now uses a new MockOidcServer.dropConnection() against a co-located path. - The origin pin compares scheme/host/port and ignores the path, so an identity provider that hosts its endpoints on a different origin than its issuer must be configured without an issuer. This matches the Python client. - allowInsecureTransport still relaxes the identity provider endpoints too (unchanged); the Python client always forces https/loopback for the IdP. Left as-is to avoid changing settled transport behavior. Adds 7 tests and updates the README OIDC section. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 +- .../client/cutlass/auth/OidcDeviceAuth.java | 332 ++++++++++++++++-- .../test/cutlass/auth/MockOidcServer.java | 18 +- .../test/cutlass/auth/OidcDeviceAuthTest.java | 197 ++++++++++- 4 files changed, 519 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 3c8a6bd02..3252102f9 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,18 @@ OidcDeviceAuth auth = OidcDeviceAuth.builder() .build(); ``` -Discovery via `fromQuestDB(...)` needs a server that advertises its device authorization endpoint through `/settings`, and the identity provider's client must have the device authorization grant enabled. +Discovery via `fromQuestDB(...)` reads the OIDC client id, scope and endpoints from the server's `/settings`, and the identity provider's client must have the device authorization grant enabled. When the server does not advertise its device authorization endpoint (today's servers), pin the identity provider by its issuer so the client can discover the endpoint from the issuer's `.well-known/openid-configuration` document: + +```java +try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "https://questdb.example.com:9000", "https://idp.example.com")) { + auth.getToken(); +} +``` By default the device authorization and token endpoints must use `https`, so tokens are never sent in cleartext; an `http` endpoint is rejected. For local development against an `http` endpoint, opt in explicitly with `.allowInsecureTransport(true)` on the builder, or `OidcDeviceAuth.fromQuestDB(url, true)`. -`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` instead of discovering it. +`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin, and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered `/settings` cannot move it. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` (optionally with `.issuer(...)`) instead of discovering it. ### Explicit Timestamps 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 index 692a35d5b..381462d2c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -132,6 +132,7 @@ public class OidcDeviceAuth implements QuietCloseable { 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 audience; private final String clientId; private final long clockSkewMillis; @@ -191,16 +192,16 @@ public static Builder builder() { * identity provider and harvest the user's authorization. Only call {@code fromQuestDB} against a * server you trust, reached over {@code https} (required by default; relaxing it with * {@link Builder#allowInsecureTransport(boolean)} removes the transport protection). When the - * server is not trusted, configure the identity provider explicitly with {@link #builder()} - * rather than discovering it. + * server is not trusted, configure the identity provider explicitly with {@link #builder()}, + * or pin it with {@link #fromQuestDB(String, 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 (an older server, or one not configured for it) + * authorization endpoint and no issuer was pinned to discover it */ public static OidcDeviceAuth fromQuestDB(String questdbUrl) { - return fromQuestDB(questdbUrl, defaultTlsConfig(), false); + return fromQuestDB(questdbUrl, null, null, defaultTlsConfig(), false); } /** @@ -209,15 +210,42 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl) { * {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, boolean allowInsecureTransport) { - return fromQuestDB(questdbUrl, defaultTlsConfig(), allowInsecureTransport); + return fromQuestDB(questdbUrl, null, null, defaultTlsConfig(), allowInsecureTransport); } /** - * Same as {@link #fromQuestDB(String)} but with an explicit TLS configuration, used both for - * the discovery request and for the later identity provider requests. + * Same as {@link #fromQuestDB(String)} but pins the identity provider by its {@code issuer} origin + * (for example {@code https://idp.example.com}). The issuer serves two roles: + *

    + *
  • when the server does not advertise the device authorization endpoint (today's servers, + * and older ones), it is discovered from the issuer's {@code .well-known/openid-configuration} + * document; the discovery origin is taken only from this out-of-band issuer, never from a value + * the server's {@code /settings} supplied, so a tampered {@code /settings} cannot choose where + * the credentials are sent;
  • + *
  • it pins the token and device authorization endpoints: either endpoint that does not belong + * to the issuer origin is rejected, so a compromised-but-TLS-valid server cannot redirect the + * sign-in to an attacker.
  • + *
+ */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer) { + return fromQuestDB(questdbUrl, issuer, null, defaultTlsConfig(), false); + } + + /** + * Same as {@link #fromQuestDB(String, String)} but lets the caller permit insecure {@code http} + * transport for the QuestDB server and the discovered identity provider endpoints (see + * {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, boolean allowInsecureTransport) { + return fromQuestDB(questdbUrl, issuer, null, defaultTlsConfig(), allowInsecureTransport); + } + + /** + * Same as {@link #fromQuestDB(String)} but with an explicit TLS configuration, used for the + * discovery request, any identity provider discovery document, and the later sign-in requests. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig) { - return fromQuestDB(questdbUrl, tlsConfig, false); + return fromQuestDB(questdbUrl, null, null, tlsConfig, false); } /** @@ -226,6 +254,23 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfigurati * (see {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport) { + return fromQuestDB(questdbUrl, null, null, tlsConfig, allowInsecureTransport); + } + + /** + * Same as {@link #fromQuestDB(String, String)} but lets the caller supply the identity provider + * discovery document URL directly (an alternative to {@code issuer}, which otherwise derives it as + * {@code {issuer}/.well-known/openid-configuration}) and an explicit TLS configuration. Either an + * {@code issuer} or a {@code discoveryUrl} pins the identity provider; pass both {@code null} to + * trust the endpoints the server advertises. + * + * @param questdbUrl the QuestDB HTTP base URL + * @param issuer the identity provider origin to pin, or {@code null} + * @param discoveryUrl the identity provider discovery document URL to pin, or {@code null} + * @param tlsConfig the TLS configuration for the discovery and sign-in requests + * @param allowInsecureTransport permits insecure {@code http} for the server and identity provider + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, String discoveryUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport) { Endpoint server = Endpoint.parse(questdbUrl); if (!allowInsecureTransport) { requireSecureTransport(server.isTls, "QuestDB server url", questdbUrl); @@ -238,20 +283,75 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfigurati if (parser.clientId.length() == 0) { throw new OidcAuthException().put("the QuestDB server does not advertise an OIDC client id [url=").put(questdbUrl).put(']'); } - if (parser.tokenEndpoint.length() == 0) { - throw new OidcAuthException().put("the QuestDB server does not advertise an OIDC token endpoint [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; + String pinnedDiscoveryUrl = discoveryUrl != null && !discoveryUrl.isEmpty() ? discoveryUrl : null; + + // When the QuestDB /settings channel is a plaintext, MITM-able http connection (only reachable + // with allowInsecureTransport; the default rejects it), the endpoints it advertises could 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 that advertises 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-supplied endpoints over such a channel. + boolean settingsSuppliedCredentials = tokenEndpoint != null || deviceAuthorizationEndpoint != null; + if (settingsSuppliedCredentials && resolvedIssuer == null && pinnedDiscoveryUrl == 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(']'); + } + + // Fall back to identity provider discovery when the server does not advertise the device + // authorization endpoint (and/or the token endpoint). This contacts the identity provider, whose + // origin must be pinned out of band: the discovery target is never derived from a value the + // server supplied, otherwise a tampered or intercepted /settings could steer discovery - and so + // the credential POSTs - to an attacker, with the co-location and issuer checks passing trivially. + if (deviceAuthorizationEndpoint == null || tokenEndpoint == null) { + if (resolvedIssuer == null && pinnedDiscoveryUrl == 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, pinnedDiscoveryUrl, 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(); + } + // adopt the issuer the discovery document declares, so the endpoint pin below binds to it + if (resolvedIssuer == null && doc.issuer.length() > 0) { + resolvedIssuer = doc.issuer.toString(); + } } - if (parser.deviceAuthorizationEndpoint.length() == 0) { + + 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("the QuestDB server does not advertise a device authorization endpoint; upgrade the server ") - .put("or configure the endpoint explicitly with OidcDeviceAuth.builder() [url=").put(questdbUrl).put(']'); + .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(parser.deviceAuthorizationEndpoint.toString()) - .tokenEndpoint(parser.tokenEndpoint.toString()) + .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint) + .tokenEndpoint(tokenEndpoint) .scope(parser.scope.length() > 0 ? parser.scope.toString() : DEFAULT_SCOPE) .groupsInToken(parser.groupsInToken) + .issuer(resolvedIssuer) .allowInsecureTransport(allowInsecureTransport) .tlsConfig(tlsConfig) .build(); @@ -427,33 +527,90 @@ private static void discardBody(Response body, int timeoutMillis) { } } + private static void discoverFromIdp(String issuer, String discoveryUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport, WellKnownDiscoveryParser parser) { + // the discovery document URL is pinned out of band (a caller-supplied discoveryUrl, else built + // from the issuer) - the caller guarantees one of the two is non-null - so the server cannot + // choose where discovery, and the credential POSTs it resolves, are aimed + String url = discoveryUrl != null ? discoveryUrl : wellKnownUrl(issuer); + Endpoint endpoint = Endpoint.parse(url); + if (!allowInsecureTransport) { + requireSecureTransport(endpoint.isTls, "OIDC issuer / discovery url", url); + } + fetchJson(endpoint, endpoint.path, tlsConfig, parser, + "could not reach the identity provider to discover OIDC settings", + "could not parse the identity provider discovery document"); + } + private static void discoverSettings(Endpoint server, ClientTlsConfiguration tlsConfig, SettingsDiscoveryParser parser) { - HttpClient client = server.isTls + fetchJson(server, appendSettingsPath(server.path), tlsConfig, parser, + "could not reach the QuestDB server to discover OIDC settings", + "could not parse the QuestDB /settings response"); + } + + private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError) { + HttpClient client = endpoint.isTls ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) : HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); JsonLexer lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES); try { - HttpClient.Request request = client.newRequest(server.host, server.port) + HttpClient.Request request = client.newRequest(endpoint.host, endpoint.port) .GET() - .url(appendSettingsPath(server.path)) + .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(); // bounded read: parseBody enforces a wall-clock deadline and a byte cap so an untrusted - // server cannot wedge discovery, and its parseLast rejects a truncated /settings document + // server cannot wedge discovery, and its parseLast rejects a truncated document parseBody(body, lexer, parser, DEFAULT_HTTP_TIMEOUT_MILLIS); } catch (HttpClientException e) { - throw new OidcAuthException(e).put("could not reach the QuestDB server to discover OIDC settings"); + throw new OidcAuthException(e).put(reachError); } catch (JsonException e) { - throw new OidcAuthException(e).put("could not parse the QuestDB /settings response"); + throw new OidcAuthException(e).put(parseError); } finally { Misc.free(lexer); Misc.free(client); } } + private static boolean isDottedIpv4(String host) { + // validate a dotted IPv4 literal (four 0-255 octets) without a DNS lookup, so a hostname that + // merely starts 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 isLoopbackHost(String host) { + // traffic to a loopback target never leaves the host, so a plaintext /settings fetch to it carries + // no network interception risk; match localhost and the whole IPv4 127.0.0.0/8 block + return host != null && (host.equalsIgnoreCase("localhost") || (host.startsWith("127.") && isDottedIpv4(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 overall wall-clock deadline and a cumulative byte // cap, so a hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming @@ -503,6 +660,12 @@ private static void requireSecureTransport(boolean isTls, String label, String u } } + private static boolean sameOrigin(Endpoint a, Endpoint b) { + // scheme (captured by isTls), host and port - the security origin; the path is deliberately not + // compared, the token and device endpoints legitimately differ in path on one authorization server + return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host); + } + private static String sanitizeForDisplay(String value) { if (value == null) { return null; @@ -539,10 +702,54 @@ private static String sanitizeForDisplay(String value) { return sink.toString(); } + private static boolean settingsChannelIsPlaintext(Endpoint server) { + // /settings reached over plaintext http to a non-loopback host is MITM-able (only possible when + // allowInsecureTransport is set; the default rejects it), so the endpoints it advertises 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) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint deviceAuthorizationEndpoint, Endpoint issuer) { + // the device code and the 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 configuration + // that splits them across origins (a tampered /settings or discovery document trying to siphon one + // off), and - when the issuer is pinned - reject either endpoint that does not belong to it. The + // pin compares origins, so an identity provider that hosts its endpoints on a different origin than + // its issuer must be configured without an issuer (or with explicit endpoints). + if (!sameOrigin(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 (!sameOrigin(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 (!sameOrigin(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 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 appendParam(StringSink sink, String name, String value) { sink.putAscii('&').putAscii(name).putAscii('=').putAscii(urlEncode(value)); } @@ -830,6 +1037,7 @@ public static final class Builder { private String deviceAuthorizationEndpoint; private boolean groupsInToken; private int httpTimeoutMillis = DEFAULT_HTTP_TIMEOUT_MILLIS; + private String issuer; private DeviceCodePrompt prompt = DeviceCodePrompt.SYSTEM_OUT; private String scope = DEFAULT_SCOPE; private ClientTlsConfiguration tlsConfig; @@ -870,10 +1078,16 @@ public OidcDeviceAuth build() { 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; if (!allowInsecureTransport) { - requireSecureTransport(Endpoint.parse(deviceAuthorizationEndpoint).isTls, "device authorization endpoint", deviceAuthorizationEndpoint); - requireSecureTransport(Endpoint.parse(tokenEndpoint).isTls, "token endpoint", tokenEndpoint); + requireSecureTransport(deviceEndpoint.isTls, "device authorization endpoint", deviceAuthorizationEndpoint); + requireSecureTransport(parsedTokenEndpoint.isTls, "token endpoint", tokenEndpoint); } + // 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(); return new OidcDeviceAuth(this, tls); } @@ -912,6 +1126,20 @@ public Builder httpTimeoutMillis(int httpTimeoutMillis) { return this; } + /** + * Pins the identity provider by its {@code issuer} origin (for example + * {@code https://idp.example.com}). When set, {@link #build()} rejects a token or device + * authorization endpoint that does not belong to this origin, so a compromised or tampered + * configuration cannot redirect the device code and refresh token to an attacker. + * {@link #fromQuestDB(String, String)} sets it for you when discovering from a server. The + * endpoints of an identity provider that hosts them on a different origin than its issuer are + * rejected when pinned; configure such a provider without an issuer. Optional. + */ + 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#SYSTEM_OUT}. @@ -1295,4 +1523,62 @@ public void onEvent(int code, CharSequence tag, int position) { } } } + + private static final class WellKnownDiscoveryParser implements JsonParser { + private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 1; + private static final int FIELD_ISSUER = 3; + private static final int FIELD_NONE = 0; + private static final int FIELD_TOKEN_ENDPOINT = 2; + final StringSink deviceAuthorizationEndpoint = new StringSink(); + final StringSink issuer = new StringSink(); + final StringSink tokenEndpoint = new StringSink(); + private int depth; + private int field = FIELD_NONE; + + @Override + public void onEvent(int code, CharSequence tag, int position) { + switch (code) { + case JsonLexer.EVT_OBJ_START: + depth++; + break; + case JsonLexer.EVT_OBJ_END: + depth--; + break; + case JsonLexer.EVT_NAME: + // the standard OIDC discovery document is a flat top-level object; only read its + // top-level keys so a nested value cannot be mistaken for an endpoint + if (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 if (Chars.equals("issuer", tag)) { + field = FIELD_ISSUER; + } else { + field = FIELD_NONE; + } + } + break; + case JsonLexer.EVT_VALUE: + if (depth == 1) { + switch (field) { + case FIELD_DEVICE_AUTHORIZATION_ENDPOINT: + putNonNull(deviceAuthorizationEndpoint, tag); + break; + case FIELD_TOKEN_ENDPOINT: + putNonNull(tokenEndpoint, tag); + break; + case FIELD_ISSUER: + putNonNull(issuer, tag); + break; + default: + break; + } + } + break; + default: + break; + } + } + } } 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 index 41541ecec..37139d6b4 100644 --- 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 @@ -66,6 +66,15 @@ 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); } @@ -257,7 +266,13 @@ private void handleConnection(Socket socket) { Request request; while ((request = readRequest(in)) != null) { requestAuthHeaders.add(request.authorization); - writeResponse(out, handler.handle(request.method, request.path, request.body)); + MockResponse response = handler.handle(request.method, request.path, request.body); + 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 @@ -275,6 +290,7 @@ public static class MockResponse { final String body; final boolean chunked; final int status; + boolean dropConnection; boolean stall; MockResponse(int status, String body, boolean chunked) { 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 index 6c53f6394..6c685ccef 100644 --- 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 @@ -57,6 +57,7 @@ public class OidcDeviceAuthTest { }; 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 { @@ -107,6 +108,38 @@ public void testAudienceParameterSentToDeviceEndpoint() throws Exception { }); } + @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 + OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/as/device") + .tokenEndpoint("https://idp.example/as/token") + .issuer("https://idp.example") + .build() + .close(); + }); + } + + @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.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 { @@ -129,6 +162,22 @@ public void testBuilderRejectsMissingRequiredOptions() { } } + @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.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(() -> { @@ -755,6 +804,94 @@ public void testEscapedVerificationUrlIsUnescapedForDisplay() throws Exception { }); } + @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(""), server.httpUrl(""), true)) { + // settings advertise groups.encoded.in.token=true, so getToken() returns the id token + Assert.assertEquals("ID-WK", auth.getToken()); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbDiscoversFromDiscoveryUrl() throws Exception { + assertMemoryLeak(() -> { + // a discovery url pins the identity provider directly (an alternative to an issuer); the device + // endpoint and the issuer to pin against both come from the discovery document + 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-DU", "ID-DU", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true)) { + Assert.assertEquals("ID-DU", auth.getToken()); + } + } + }); + } + + @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.fromQuestDB(server.httpUrl(""), server.httpUrl(""), true); + 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(() -> { @@ -779,6 +916,29 @@ public void testFromQuestDbDiscoveryRunsFlow() throws Exception { }); } + @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.fromQuestDB(server.httpUrl(""), "https://idp.attacker.example", true); + Assert.fail("expected the issuer pin to reject the 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 testFromQuestDbRejectsInsecureServerUrl() { // the default-secure fromQuestDB overload must reject an http:// QuestDB server url (the discovery @@ -1167,10 +1327,10 @@ public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exc @Test(timeout = 30_000) public void testMalformedEndpointDoesNotLeakNativeMemory() { - // allowInsecureTransport skips build()'s own Endpoint.parse, so the constructor is the first to - // parse and throw on this malformed url; the native JSON lexer must not have been allocated yet - // (otherwise the never-returned instance leaks it). Measure the parser tag directly - the - // module's assertMemoryLeak does not flag a single-tag growth. + // build() parses the endpoints up front (for the co-location / issuer-pin checks) and throws on + // this malformed url before the constructor allocates the native JSON lexer, so the never-returned + // instance cannot leak it. Measure the parser tag directly - the module's assertMemoryLeak does not + // flag a single-tag growth. long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); try { OidcDeviceAuth.builder() @@ -1359,19 +1519,21 @@ public void testOutOfRangePollIntervalAndExpiryAreClamped() throws Exception { @Test(timeout = 30_000) public void testPersistentTransportFailureDuringPollingAborts() throws Exception { assertMemoryLeak(() -> { - // the device endpoint works, but the token endpoint is unreachable; polling must abort with - // the underlying transport error after a few attempts, not retry silently until the code expires - int deadPort; - try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { - deadPort = probe.getLocalPort(); - } // closed now - nothing listens on deadPort - MockOidcServer.Handler handler = (method, path, body) -> - MockOidcServer.json(200, deviceAuthorizationJson(1, 10)); + // the device endpoint works, but the (co-located) token endpoint drops the connection on every + // poll; polling must abort with the underlying transport error after a few attempts, not retry + // silently until the code expires. 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, 10)); + } + return MockOidcServer.dropConnection(); + }; try (MockOidcServer server = new MockOidcServer(handler)) { try (OidcDeviceAuth auth = OidcDeviceAuth.builder() .clientId("questdb") .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) - .tokenEndpoint("http://127.0.0.1:" + deadPort + "/token") + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { @@ -2103,4 +2265,13 @@ private static String tokenJson(String accessToken, String idToken, String refre 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 + "\"" + + "}"; + } } From dc02c1611088d35194d86054a367b9687be79618 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 11:52:59 +0100 Subject: [PATCH 013/192] Validate OIDC URLs and enforce the discoveryUrl pin Endpoint.parse now rejects control characters and whitespace anywhere in the url before splitting it. The host was already checked, but the path was not, so a tampered /settings or discovery document could carry a CR/LF in an endpoint path that the JSON lexer decodes and postForm writes verbatim onto the request line via .url(endpoint.path) - a header-injection / request-smuggling vector that the origin pin (which compares scheme/host/port only) does not catch. Validating the whole url up front also keeps it safe to echo in the parse error messages. fromQuestDB now derives the pin origin from a caller-supplied discoveryUrl when no issuer was resolved. Previously a discoveryUrl pin only took effect when discovery actually ran (an endpoint missing from /settings); when /settings advertised both endpoints the discovery branch was skipped and validateEndpointOrigins ran with a null issuer, so a compromised server could advertise both endpoints at an attacker origin and slip past the pin. The discoveryUrl pin now behaves like the issuer pin on every construction path. Adds regression tests for both: a CR/LF-injected advertised endpoint, path and query cases in Endpoint.parse, and discoveryUrl-pin accept and reject against on- and off-origin endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 30 +++++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 85 +++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) 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 index 381462d2c..160b6229a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -334,6 +334,16 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin } } + // A caller-supplied discoveryUrl pins the identity provider just as an issuer does. When /settings + // advertised both endpoints the discovery branch above was skipped, so it adopted no issuer from a + // discovery document (and a document without an "issuer" field would not have either); derive the + // pin origin from the discoveryUrl itself so validateEndpointOrigins still rejects an endpoint that + // does not belong to it. Without this, a tampered /settings advertising both endpoints at one + // attacker origin would slip past a discoveryUrl pin - the co-location check alone passes trivially. + if (resolvedIssuer == null && pinnedDiscoveryUrl != null) { + resolvedIssuer = originOf(Endpoint.parse(pinnedDiscoveryUrl)); + } + if (tokenEndpoint == null) { throw new OidcAuthException() .put("could not resolve the OIDC token endpoint from the QuestDB /settings response or the identity ") @@ -1287,6 +1297,18 @@ static Endpoint parse(String url) { if (url == null) { throw new OidcAuthException("url is required"); } + // Reject control characters and whitespace anywhere in the url, before it is split or used. A + // smuggled CR/LF (or other control char) in the host would corrupt the outbound Host header; + // in the path or query it would inject 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. Validating up front also keeps the raw + // url safe to echo in the parse error messages below. + for (int i = 0, n = url.length(); i < n; i++) { + char c = url.charAt(i); + if (c <= ' ' || c == 0x7f) { + throw new OidcAuthException().put("invalid url, it contains an illegal character [url=").put(sanitizeForDisplay(url)).put(']'); + } + } int schemeEnd = url.indexOf("://"); if (schemeEnd < 0) { throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']'); @@ -1329,14 +1351,6 @@ static Endpoint parse(String url) { if (host.isEmpty()) { throw new OidcAuthException().put("invalid url, the host is empty [url=").put(url).put(']'); } - for (int i = 0, n = host.length(); i < n; i++) { - char c = host.charAt(i); - if (c <= ' ' || c == 0x7f) { - // a host carrying control characters or whitespace (e.g. a smuggled CR/LF) would corrupt - // the outbound Host header, so reject it rather than pass it through to the transport - throw new OidcAuthException().put("invalid url, the host contains an illegal character [url=").put(url).put(']'); - } - } return new Endpoint(host, port, path, isTls); } } 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 index 6c685ccef..2b1d2ed1c 100644 --- 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 @@ -717,6 +717,11 @@ public void testEndpointParseRejectsMalformedUrls() { 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"); } @Test(timeout = 30_000) @@ -916,6 +921,61 @@ public void testFromQuestDbDiscoveryRunsFlow() throws Exception { }); } + @Test(timeout = 30_000) + public void testFromQuestDbDiscoveryUrlPinAcceptsOnOriginAdvertisedEndpoints() throws Exception { + assertMemoryLeak(() -> { + // /settings advertises both endpoints on the same origin as the pinned discoveryUrl, so the pin + // is satisfied and the flow completes - and without a discovery round-trip, since the discovery + // branch is skipped when both endpoints are already advertised + AtomicReference serverRef = new AtomicReference<>(); + AtomicBoolean wellKnownHit = new AtomicBoolean(false); + 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 (WELL_KNOWN_PATH.equals(path)) { + wellKnownHit.set(true); + 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-DUP", "ID-DUP", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true)) { + Assert.assertEquals("ID-DUP", auth.getToken()); + } + Assert.assertFalse("discovery must be skipped when /settings advertises both endpoints", wellKnownHit.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbDiscoveryUrlPinRejectsOffOriginAdvertisedEndpoints() throws Exception { + assertMemoryLeak(() -> { + // /settings advertises both endpoints directly (so the discovery branch is skipped), but they do + // not belong to the pinned discoveryUrl origin; the discoveryUrl pin must reject them just as an + // issuer pin does, rather than let a compromised server redirect the sign-in to its chosen origin + 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.fromQuestDB(server.httpUrl(""), null, "https://trusted-idp.example/.well-known/openid-configuration", null, true); + Assert.fail("expected the discoveryUrl pin to reject the 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 testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws Exception { assertMemoryLeak(() -> { @@ -939,6 +999,31 @@ public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws }); } + @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.fromQuestDB(server.httpUrl(""), true); + 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 From e523db28f634df8669518607d60e9a65510755ca Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 12:23:11 +0100 Subject: [PATCH 014/192] Reject display-unsafe characters in OIDC URLs Endpoint.parse already rejected control characters and whitespace in the url, which kept it safe to echo into the exception messages once it passed validation. That scan did not catch bidi, zero-width or other format characters (U+202E, U+200B, U+FEFF, the Cf category, and the supplementary-plane tag characters), so a tampered /settings or discovery endpoint url could still smuggle one into an OidcAuthException message and reorder, hide or forge the log line it lands in. The url scan now runs per code point and also rejects anything isUnsafeForDisplay flags, so an OIDC url may carry no control, whitespace or display-unsafe character. Every raw url echo in Endpoint.parse, requireSecureTransport and fromQuestDB is therefore safe on screen as well as on the wire, and the rejection message sanitizes the url it reports. Adds a regression test covering a right-to-left override, a zero-width space, the BOM and a supplementary-plane tag character. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 22 ++++++++------ .../test/cutlass/auth/OidcDeviceAuthTest.java | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) 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 index 160b6229a..655fc88a8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1297,17 +1297,21 @@ static Endpoint parse(String url) { if (url == null) { throw new OidcAuthException("url is required"); } - // Reject control characters and whitespace anywhere in the url, before it is split or used. A - // smuggled CR/LF (or other control char) in the host would corrupt the outbound Host header; - // in the path or query it would inject 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. Validating up front also keeps the raw - // url safe to echo in the parse error messages below. - for (int i = 0, n = url.length(); i < n; i++) { - char c = url.charAt(i); - if (c <= ' ' || c == 0x7f) { + // 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 would corrupt + // the outbound Host header; in the path or query it would inject 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 character (isUnsafeForDisplay, scanned per code point so a + // supplementary-plane one is not missed) would reorder, hide or forge the text when the url is + // echoed into a log line or the parse error messages below. Rejecting up front keeps the raw url + // safe both 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); } int schemeEnd = url.indexOf("://"); if (schemeEnd < 0) { 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 index 2b1d2ed1c..4df859d30 100644 --- 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 @@ -698,6 +698,35 @@ public void testDuplicateJsonKeysDoNotConcatenate() throws Exception { }); } + @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.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 testEndpointParseRejectsMalformedUrls() { // Endpoint.parse rejects malformed endpoint URLs at build time From caa50877210f90b3dc590fb785f7f86f01a2db16 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 13:19:58 +0100 Subject: [PATCH 015/192] Strip unpaired surrogates from OIDC display text isUnsafeForDisplay now treats an unpaired UTF-16 surrogate as unsafe, so a lone surrogate half - which JsonLexer emits verbatim for a single backslash-u-XXXX escape and which codePointAt surfaces as a SURROGATE code point - is stripped from a user_code, verification_uri or error string before it reaches a terminal or a log line. A valid high+low pair is still reassembled by codePointAt and judged on its real category, so a legitimate emoji survives. The method comment is corrected too: codePointAt in the callers reassembles pairs, not the lexer. close() and the class Javadoc no longer claim an in-flight sign-in is cancelled "promptly". The cancel flag is observed between polls (within about 100ms) but a poll request already in flight is not interrupted, so close() can take up to one HTTP request timeout to return - still far short of the device-code lifetime. The docs now say so. Adds tests: lone high and low surrogates are stripped from the device challenge while an emoji survives; and the private isLoopbackHost classifier (which gates the plaintext-channel MITM pin) is pinned for localhost and the 127.0.0.0/8 block, and against non-loopback and spoofing hosts such as 127.evil.com, localhost.evil.com, 127.1 and 127.0.0.256. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/auth/OidcAuthException.java | 11 ++- .../client/cutlass/auth/OidcDeviceAuth.java | 23 +++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 90 +++++++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) 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 index fa1314d4c..b0a6467e1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -68,9 +68,13 @@ public static OidcAuthException oauthError(CharSequence error, CharSequence desc } // Reports characters that must never reach a terminal or a log line. The parameter is a Unicode code - // point, not a UTF-16 unit, so a supplementary-plane (>= U+10000) format or control character - a - // surrogate pair the JSON lexer reassembled - is judged as one character rather than as two surrogate - // halves that each look harmless (the gap that let an invisible U+E00xx "tag" char slip through). + // point, not a UTF-16 unit: the callers (sanitizeForDisplay / putSanitized) scan with codePointAt, which + // reassembles a valid high+low surrogate pair - the form a supplementary-plane char arrives in after the + // JSON lexer emits each backslash-u-XXXX escape verbatim - into one code point, so a supplementary-plane format + // or control char is judged as one character rather than as two surrogate halves that each look harmless + // (the gap that let an invisible U+E00xx "tag" char slip through). An unpaired surrogate (a lone half the + // lexer never reassembled) surfaces from codePointAt as a SURROGATE code point and is stripped too, as it + // carries no displayable meaning. // Beyond the C0/C1 controls and DEL that isISOControl covers, this strips the Unicode "format" // category (Cf) - zero-width joiners, the byte-order mark, the bidirectional embedding/override/isolate // controls, and the U+E00xx tag characters - plus an explicit bidi/BOM set, so an attacker-influenced @@ -80,6 +84,7 @@ public static OidcAuthException oauthError(CharSequence error, CharSequence desc static boolean isUnsafeForDisplay(int c) { return Character.isISOControl(c) || Character.getType(c) == Character.FORMAT + || Character.getType(c) == Character.SURROGATE // unpaired surrogate (lone half), no displayable meaning || (c >= 0x202A && c <= 0x202E) // LRE, RLE, PDF, LRO, RLO || (c >= 0x2066 && c <= 0x2069) // LRI, RLI, FSI, PDI || c == 0x200E || c == 0x200F // LRM, RLM 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 index 655fc88a8..b91d97b42 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -88,8 +88,12 @@ * blocks behind it - but {@link #getTokenSilently()} does not: it never waits for an in-flight * sign-in, it fails fast with an {@link OidcAuthException}, so a request/flush path is never * stalled. To abort a sign-in that is waiting, call {@link #close()} from another thread: it - * cancels the in-flight flow, which then fails promptly with an {@link OidcAuthException} rather - * than running to the device-code timeout. + * signals the in-flight flow to stop, which then fails with an {@link OidcAuthException} rather + * than polling on until the device code expires. Cancellation is observed between polls (within + * about 100ms while a poll interval is being waited out); a poll request already in flight is not + * interrupted mid-request, so the abort - and {@link #close()} itself - can take up to one HTTP + * request timeout (see {@link Builder#httpTimeoutMillis(int)}), still far short of the device-code + * lifetime. *

* Instances are interactive by design and hold a network connection; close them when done. * Token state lives in memory only and does not survive a restart of the process. @@ -385,16 +389,21 @@ public void clearCache() { /** * Frees the network connections and native buffers this instance holds. If a {@link #getToken()} - * sign-in is in flight on another thread, {@code close()} cancels it, so the blocked sign-in fails - * promptly with an {@link OidcAuthException} instead of polling to the device-code timeout. Safe to - * call more than once. After close, {@link #getToken()} and {@link #clearCache()} throw. + * sign-in is in flight on another thread, {@code close()} signals it to stop, so the sign-in fails + * with an {@link OidcAuthException} instead of polling on until the device code expires. The signal + * is observed between polls (within about 100ms while a poll interval is being waited out); a poll + * request already in flight is not interrupted, so {@code close()} acquires the instance lock - and + * returns - only once that request finishes or times out, i.e. after at most one HTTP request timeout + * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. Safe to call more + * than once. After close, {@link #getToken()} and {@link #clearCache()} throw. */ @Override public void close() { // flag cancellation before taking the lock: getToken() holds the lock for the whole interactive // flow, so close() signals the in-flight sign-in to stop with a lock-free volatile write, then - // acquires the lock - which the now-cancelled flow releases promptly - and frees the native - // resources. close() never frees while a flow holds the lock, so there is no use-after-free + // acquires the lock - which the now-cancelled flow releases once it observes the flag (between + // polls, or after an in-flight poll request returns) - and frees the native resources. close() + // never frees while a flow holds the lock, so there is no use-after-free closed = true; lock.lock(); try { 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 index 4df859d30..ae20f4123 100644 --- 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 @@ -39,6 +39,7 @@ import org.junit.Assert; import org.junit.Test; +import java.lang.reflect.Method; import java.net.InetAddress; import java.net.ServerSocket; import java.util.concurrent.CountDownLatch; @@ -253,6 +254,46 @@ public void testChallengeStripsControlCharactersFromDisplayFields() throws Excep }); } + @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.getToken()); + 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(() -> { @@ -1439,6 +1480,46 @@ public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exc }); } + @Test(timeout = 30_000) + public void testLoopbackHostClassifierAcceptsLoopbackForms() throws Exception { + // localhost (any case) and the whole 127.0.0.0/8 block are loopback: a plaintext /settings fetch to + // them never leaves the host, so settingsChannelIsPlaintext correctly skips the plaintext-channel + // pin. This is the pin's only exercised exemption, since MockOidcServer binds to loopback. + 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 (int i = 0; i < loopback.length; i++) { + Assert.assertTrue("expected loopback: [" + loopback[i] + "]", invokeIsLoopbackHost(loopback[i])); + } + } + + @Test(timeout = 30_000) + public void testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing() throws Exception { + // every other host must classify as non-loopback so the plaintext-channel MITM pin FIRES over http - + // the firing path the loopback-bound test mock cannot reach end to end. A classifier that accepted + // any of these as loopback would silently disable the pin for a tampered /settings endpoint. + String[] notLoopback = { + null, "", + "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", "::1" + }; + for (int i = 0; i < notLoopback.length; i++) { + Assert.assertFalse("expected non-loopback: [" + notLoopback[i] + "]", invokeIsLoopbackHost(notLoopback[i])); + } + } + @Test(timeout = 30_000) public void testMalformedEndpointDoesNotLeakNativeMemory() { // build() parses the endpoints up front (for the co-location / issuer-pin checks) and throws on @@ -2296,6 +2377,7 @@ private static void assertNoUnsafeDisplayChars(String value) { 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 @@ -2316,6 +2398,14 @@ private static String deviceAuthorizationJson(int interval, int expiresIn) { + "}"; } + // 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 invokeIsLoopbackHost(String host) throws Exception { + Method m = OidcDeviceAuth.class.getDeclaredMethod("isLoopbackHost", String.class); + m.setAccessible(true); + return (boolean) m.invoke(null, host); + } + // 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 From 7266d47a98cf59e8c2744bd52ecb1fa58ecf2c15 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 13:41:20 +0100 Subject: [PATCH 016/192] Clamp slow_down interval and reset parser fields The poll loop now clamps the slow_down-inflated interval to the same MAX_POLL_INTERVAL_SECONDS cap the initial interval already respects, so repeated slow_down responses from the identity provider cannot grow the wait without bound. The device-authorization, token and well-known parsers now reset their current field to FIELD_NONE after each value, matching SettingsDiscoveryParser. The parsers are not currently confusable - in well-formed JSON a name event always sets the field before the next value, array elements arrive as EVT_ARRAY_VALUE, and nested values are filtered by the depth check - so this is a defensive consistency fix that removes a latent field-confusion foot-gun rather than a behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/cutlass/auth/OidcDeviceAuth.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index b91d97b42..8d0aab6cb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -850,7 +850,9 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS } else { consecutiveTransportErrors = 0; if (result == POLL_SLOW_DOWN) { - intervalMillis += SLOW_DOWN_INCREMENT_SECONDS * 1000L; + // grow the interval per RFC 8628, but keep it within the same cap as the initial + // value so repeated slow_down responses cannot inflate the wait without bound + intervalMillis = Math.min(intervalMillis + SLOW_DOWN_INCREMENT_SECONDS * 1000L, MAX_POLL_INTERVAL_SECONDS * 1000L); } } } catch (HttpClientException e) { @@ -1282,6 +1284,7 @@ public void onEvent(int code, CharSequence tag, int position) { break; } } + field = FIELD_NONE; break; default: break; @@ -1544,6 +1547,7 @@ public void onEvent(int code, CharSequence tag, int position) { break; } } + field = FIELD_NONE; break; default: break; @@ -1602,6 +1606,7 @@ public void onEvent(int code, CharSequence tag, int position) { break; } } + field = FIELD_NONE; break; default: break; From 6f02ccf2735aaf81b4f58bc7e06f03f66b978801 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 14:00:23 +0100 Subject: [PATCH 017/192] Simplify JSON unescape and tidy method ordering JsonLexer.unescape no longer re-scans the value from the start to re-find the backslash the lexer already flagged via hasEscape; it walks the value once, copying plain characters and resolving escapes in place. That drops the now-dead "no escapes" early return and the separate prefix copy, so an escaped value is traversed about twice (decode then unescape) instead of three times. parseHex4 looks the hex digit up in the shared Numbers.hexNumbers table instead of Character.digit, keeping the same -1-on-non-hex contract. All of this is on the cold error/discovery/auth parse path, never on ingestion. Reorders pollForToken ahead of pollOnce so the private methods stay in alphabetical order; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 78 +++++++++---------- .../client/cutlass/json/JsonLexer.java | 20 +++-- 2 files changed, 48 insertions(+), 50 deletions(-) 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 index 8d0aab6cb..b798572cb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -791,45 +791,6 @@ private boolean isHttpStatusSuccess() { return responseStatus.length() > 0 && responseStatus.charAt(0) == '2'; } - private int pollOnce(String deviceCode) { - formSink.clear(); - formSink.putAscii("grant_type=").putAscii(urlEncode(GRANT_TYPE_DEVICE_CODE)); - appendParam(formSink, "device_code", deviceCode); - appendParam(formSink, "client_id", clientId); - - tokenParser.clear(); - // a transport failure here propagates to pollForToken, which retries a brief blip but aborts - // on a persistent failure 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, so handle the - // OAuth error first - 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); - } - // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx status is a - // malformed or hostile answer - charge it to the transport-error budget rather than trusting it - if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { - if (isHttpStatusSuccess()) { - storeTokens(tokenParser); - return POLL_SUCCESS; - } - return POLL_TRANSIENT_ERROR; - } - // no tokens and no OAuth error: a 2xx is a definitive but malformed answer and aborts; a non-2xx - // (a gateway 5xx, an empty body) is a transport-class blip - retry rather than abort the sign-in - if (isHttpStatusSuccess()) { - throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); - } - return POLL_TRANSIENT_ERROR; - } - private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) { final long deadlineNanos = System.nanoTime() + expiresInSeconds * 1_000_000_000L; long intervalMillis = (long) intervalSeconds * 1000L; @@ -880,6 +841,45 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS } } + private int pollOnce(String deviceCode) { + formSink.clear(); + formSink.putAscii("grant_type=").putAscii(urlEncode(GRANT_TYPE_DEVICE_CODE)); + appendParam(formSink, "device_code", deviceCode); + appendParam(formSink, "client_id", clientId); + + tokenParser.clear(); + // a transport failure here propagates to pollForToken, which retries a brief blip but aborts + // on a persistent failure 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, so handle the + // OAuth error first - 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); + } + // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx status is a + // malformed or hostile answer - charge it to the transport-error budget rather than trusting it + if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { + if (isHttpStatusSuccess()) { + storeTokens(tokenParser); + return POLL_SUCCESS; + } + return POLL_TRANSIENT_ERROR; + } + // no tokens and no OAuth error: a 2xx is a definitive but malformed answer and aborts; a non-2xx + // (a gateway 5xx, an empty body) is a transport-class blip - retry rather than abort the sign-in + if (isHttpStatusSuccess()) { + throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); + } + return POLL_TRANSIENT_ERROR; + } + private void postForm(Endpoint endpoint, JsonParser parser) { HttpClient client = httpClient(endpoint.isTls); HttpClient.Request request = client.newRequest(endpoint.host, endpoint.port) 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 b2ba8d5ee..3f28b5b0d 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 @@ -297,7 +297,10 @@ private static boolean isNotATerminator(char c) { private static int parseHex4(CharSequence value, int offset) { int result = 0; for (int j = 0; j < 4; j++) { - int digit = Character.digit(value.charAt(offset + j), 16); + final char c = value.charAt(offset + j); + // direct lookup in the shared hex table (returns -1 for a non-hex char), 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; } @@ -350,22 +353,17 @@ private CharSequence getCharSequence(long lo, long hi, int position, boolean has } // the decode above assembled the raw bytes between the quotes verbatim; resolve JSON string escape // sequences only when the scan actually saw a backslash. The common no-escape value (and every - // escape-free name) returns the assembled sink directly, instead of unescape() rescanning it from - // the start just to rediscover that there was nothing to unescape + // escape-free name) skips unescape() entirely and returns the assembled sink directly. return hasEscape ? unescape(sink) : sink; } private CharSequence unescape(CharSequence raw) { + // called only when the scan saw a backslash (hasEscape), so at least one escape is present; walk the + // value once, copying plain characters and resolving each escape in place. No separate leading scan + // to re-find the first backslash - the lexer already proved one exists. final int n = raw.length(); - int i = 0; - while (i < n && raw.charAt(i) != '\\') { - i++; - } - if (i == n) { - return raw; // no escapes - the common case, return the assembled value unchanged - } unescapeSink.clear(); - unescapeSink.put(raw, 0, i); + int i = 0; while (i < n) { char c = raw.charAt(i); if (c != '\\' || i + 1 >= n) { From 9da26c14a6ac7c7c861704977fd8dba3af0b983d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 15:42:32 +0100 Subject: [PATCH 018/192] Test the OIDC response body size cap The 4 MiB response-body cap (MAX_RESPONSE_BODY_BYTES) that bounds the OIDC device flow against a hostile or MITM'd server streaming an endless body had no test coverage on the parseBody path. Add an oversizedJson() mode to MockOidcServer that streams a chunked, mostly-whitespace body past the cap, and a test that drives discovery against it and asserts the bounded read aborts with the size-limit error - which also confirms the token-bearing body never reaches the message. The body is whitespace so the lexer keeps consuming until the byte cap trips, instead of hitting its per-value length limit first. Verified both ways: the test passes with the 4 MiB cap and fails when the cap is disabled, where the full body is read and parsing fails with "Unterminated object" instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/cutlass/auth/MockOidcServer.java | 43 +++++++++++++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 23 ++++++++++ 2 files changed, 66 insertions(+) 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 index 37139d6b4..9d928be63 100644 --- 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 @@ -36,6 +36,7 @@ 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; @@ -79,6 +80,16 @@ 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 stall() { MockResponse response = new MockResponse(200, "", true); response.stall = true; @@ -215,7 +226,38 @@ private static void writeChunked(OutputStream out, byte[] body) throws IOExcepti 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.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 @@ -291,6 +333,7 @@ public static class MockResponse { final boolean chunked; final int status; boolean dropConnection; + long oversizedBodyBytes; boolean stall; MockResponse(int status, String body, boolean chunked) { 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 index ae20f4123..edf35a184 100644 --- 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 @@ -1711,6 +1711,29 @@ public void testOutOfRangePollIntervalAndExpiryAreClamped() throws Exception { }); } + @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.fromQuestDB(server.httpUrl(""), true); + 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 testPersistentTransportFailureDuringPollingAborts() throws Exception { assertMemoryLeak(() -> { From 697f49a9539a39017c1d1c7a90818a12d3635304 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 19 Jun 2026 15:55:49 +0100 Subject: [PATCH 019/192] Harden OIDC device-flow status and timeout checks Three small fixes to the OIDC device authorization flow, all in OidcDeviceAuth: - runDeviceFlow now rejects a non-2xx device authorization response. Previously it trusted any body that carried device_code/user_code/ verification_uri and no OAuth error, so a non-2xx response would prompt the user and start polling. It now applies the same 2xx gate pollOnce and tryRefresh already use before trusting a body. - pollForToken checks the device-code deadline at the top of the loop and never sleeps past it, so an expiry that elapses during a sleep times out promptly instead of after one more wasted poll and up to a full extra poll interval. - tryRefresh drops an unreachable branch that rethrew on an OAuth error. postForm only throws on a parse failure here, and a real OAuth error arrives in tokenParser.error (handled by the hasRequiredToken check), so the branch was dead. No behaviour change. Add testNonSuccessDeviceAuthorizationResponseRejected covering the new 2xx gate; it fails without the check (the 403 is accepted, the user is prompted, and polling fails later instead). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 28 ++++++++++++------- .../test/cutlass/auth/OidcDeviceAuthTest.java | 22 +++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) 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 index b798572cb..386674b8c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -797,6 +797,11 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS int consecutiveTransportErrors = 0; while (true) { throwIfClosed(); + // check the deadline before polling so an expiry that elapsed during the previous sleep aborts + // here, rather than 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(deviceCode); if (result == POLL_SUCCESS) { @@ -834,10 +839,9 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS throw e; } } - if (System.nanoTime() >= deadlineNanos) { - throw new OidcAuthException("timed out waiting for authorization, the device code expired; please retry"); - } - sleepBetweenPolls(intervalMillis); + // wait for the next poll, but 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)); } } @@ -934,6 +938,12 @@ private void runDeviceFlow() { 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 that carries no OAuth + // error (handled above) is a malformed or hostile answer; 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(']'); + } if (deviceAuthParser.deviceCode.length() == 0 || deviceAuthParser.userCode.length() == 0 || deviceAuthParser.verificationUri.length() == 0) { throw new OidcAuthException().put("incomplete device authorization response from the identity provider [httpStatus=").put(responseStatus).put(']'); @@ -1019,12 +1029,10 @@ private boolean tryRefresh() { // could not reach the token endpoint, fall back to the interactive flow return false; } catch (OidcAuthException e) { - // a garbled / unparseable refresh response is a transient blip, not a definitive answer; - // fall back to the interactive flow rather than fail the whole getToken() call. A genuine - // OAuth error arrives in tokenParser.error (handled below), not as a thrown oauthError here - if (e.getOauthError() != null) { - throw e; - } + // postForm only throws an OidcAuthException on a parse failure (a garbled / unparseable refresh + // response), never an OAuth error: a genuine OAuth error arrives in tokenParser.error and is + // handled by the hasRequiredToken check below. So treat this as a transient blip and fall back to + // the interactive flow rather than fail the whole getToken() call return false; } // only treat the refresh as a success if a clean 2xx response (no OAuth error) returned the 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 index edf35a184..4a56e7bbf 100644 --- 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 @@ -1564,6 +1564,28 @@ public void testNoAccessTokenWhenGroupsDisabledFails() throws Exception { }); } + @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))) { + try { + auth.getToken(); + Assert.fail("expected the non-2xx device authorization response to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response from the device authorization endpoint")); + } + 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(() -> { From 6e97d14ba8027f447b7bb43550398b15aebe0eb7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 21 Jun 2026 21:04:50 +0100 Subject: [PATCH 020/192] Pin OIDC discovery to the discoveryUrl origin A discoveryUrl pins the identity provider, yet fromQuestDB adopted the issuer the discovery document declared about itself and validated the token and device endpoints against that, never against the pinned discoveryUrl origin. A document served at the pinned url could therefore name an attacker issuer, co-locate both endpoints under it, and route the device code and the long-lived refresh token there while the co-location and issuer checks passed trivially - so the discoveryUrl pin did not in fact pin the provider, contradicting its documented guarantee. Reject a document whose own issuer sits on a different origin than the pinned discoveryUrl (RFC 8414 section 3.3), and derive the endpoint pin from the discoveryUrl origin rather than the document's self-declared issuer. An identity provider that serves its discovery document on a different origin than its endpoints must instead be configured with explicit endpoints via OidcDeviceAuth.builder(). The issuer-pinned path is unchanged: it already binds the endpoints to the caller-supplied issuer. testFromQuestDbDiscoveryUrlPinRejectsForeign IssuerInDocument covers the new rejection and fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 33 ++++++++++++----- .../test/cutlass/auth/OidcDeviceAuthTest.java | 36 +++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) 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 index 386674b8c..89ec7a505 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -332,18 +332,33 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin if (tokenEndpoint == null && doc.tokenEndpoint.length() > 0) { tokenEndpoint = doc.tokenEndpoint.toString(); } - // adopt the issuer the discovery document declares, so the endpoint pin below binds to it - if (resolvedIssuer == null && doc.issuer.length() > 0) { - resolvedIssuer = doc.issuer.toString(); + // The discovery origin is pinned out of band - the caller's issuer, else the discoveryUrl origin + // (derived after this block) - and that pin, never an issuer the document declares about itself, + // is the trust anchor the endpoint pin binds to. When discovery ran off a pinned discoveryUrl (no + // caller issuer), reject a document whose own "issuer" sits on a different origin (RFC 8414 + // section 3.3): otherwise a tampered or content-injected document at the pinned url could name an + // attacker issuer, co-locate both endpoints under it, and route the device code and long-lived + // refresh token there while the co-location and issuer checks below passed trivially. An identity + // provider that serves its discovery document on a different origin than its endpoints must + // instead be configured with explicit endpoints via OidcDeviceAuth.builder(). + if (resolvedIssuer == null && pinnedDiscoveryUrl != null && doc.issuer.length() > 0) { + Endpoint docIssuer = Endpoint.parse(doc.issuer.toString()); + Endpoint discoveryEndpoint = Endpoint.parse(pinnedDiscoveryUrl); + if (!sameOrigin(docIssuer, discoveryEndpoint)) { + throw new OidcAuthException() + .put("the OIDC discovery document declares an issuer (").put(originOf(docIssuer)) + .put(") on a different origin than the pinned discovery url (").put(originOf(discoveryEndpoint)) + .put("); refusing to send credentials to an issuer outside the pinned discovery origin"); + } } } - // A caller-supplied discoveryUrl pins the identity provider just as an issuer does. When /settings - // advertised both endpoints the discovery branch above was skipped, so it adopted no issuer from a - // discovery document (and a document without an "issuer" field would not have either); derive the - // pin origin from the discoveryUrl itself so validateEndpointOrigins still rejects an endpoint that - // does not belong to it. Without this, a tampered /settings advertising both endpoints at one - // attacker origin would slip past a discoveryUrl pin - the co-location check alone passes trivially. + // A caller-supplied discoveryUrl pins the identity provider just as an issuer does: derive the pin + // origin from the discoveryUrl itself so validateEndpointOrigins rejects any endpoint - read from the + // discovery document above, or advertised by /settings when it supplied both endpoints and the + // discovery branch was skipped - that does not belong to it. Without this, a tampered response + // advertising both endpoints at one attacker origin would slip past a discoveryUrl pin, the + // co-location check alone passing trivially. if (resolvedIssuer == null && pinnedDiscoveryUrl != null) { resolvedIssuer = originOf(Endpoint.parse(pinnedDiscoveryUrl)); } 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 index 4a56e7bbf..14457e8ce 100644 --- 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 @@ -1023,6 +1023,42 @@ public void testFromQuestDbDiscoveryUrlPinAcceptsOnOriginAdvertisedEndpoints() t }); } + @Test(timeout = 30_000) + public void testFromQuestDbDiscoveryUrlPinRejectsForeignIssuerInDocument() throws Exception { + assertMemoryLeak(() -> { + // RFC 8414 section 3.3: discovery runs against the pinned discoveryUrl, and the document it + // returns declares an issuer - with co-located token and device endpoints - on an attacker + // origin. The discoveryUrl pins the identity provider to its own origin, so a document that + // vouches for a foreign issuer (and would route the device code and the long-lived refresh token + // there) must be rejected, rather than trusted just because its endpoints agree with its own + // self-declared issuer and the co-location check passes trivially. + MockOidcServer.Handler handler = (method, path, body) -> { + if (SETTINGS_PATH.equals(path)) { + // OIDC enabled, with a client id, but neither endpoint advertised - so both the token and + // the device endpoint must be read from the discovery document below + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.scope\":\"openid groups\"" + + "}}"); + } + // the document served at the pinned (loopback) discoveryUrl points everything at an attacker origin + return MockOidcServer.json(200, wellKnownJson( + "https://attacker.example/device", + "https://attacker.example/token", + "https://attacker.example")); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + try { + OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true); + Assert.fail("expected the discoveryUrl pin to reject a document declaring a foreign issuer"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origin than the pinned discovery url")); + } + } + }); + } + @Test(timeout = 30_000) public void testFromQuestDbDiscoveryUrlPinRejectsOffOriginAdvertisedEndpoints() throws Exception { assertMemoryLeak(() -> { From 4dbce9e6d1a2c0c8cecf858342460acac21caa5d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 21 Jun 2026 23:20:52 +0100 Subject: [PATCH 021/192] Reject a non-numeric OIDC HTTP status code readResponse copied the response status code into a sink that later appears in OidcAuthException messages. A well-formed status code is bare digits, but the HTTP header parser keeps the status-line token verbatim apart from SP/CR/LF, so a hostile or MITM'd identity provider could splice ESC or other control bytes into it - smuggling ANSI sequences into a log or terminal, or fabricating a leading digit that passes the 2xx success gate. Validate the status code as it is captured: on any non-digit byte, drain the body so the keep-alive connection stays usable, then reject the response with a message that echoes none of its bytes. A clean status is copied digit by digit, so every later [httpStatus=...] echo is bare digits. testNonNumericStatusCodeRejected drives a status code with a spliced ANSI reset and asserts the rejection; it fails without the fix. The new MockOidcServer.raw() helper writes a verbatim response so a test can craft a malformed status line. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 17 +++++++- .../test/cutlass/auth/MockOidcServer.java | 15 +++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 41 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) 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 index 89ec7a505..f0dc3cb1a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -919,11 +919,24 @@ private void readResponse(HttpClient.ResponseHeaders response, JsonParser parser // 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) { - responseStatus.put(statusCode.asAsciiCharSequence()); + // 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 of its bytes - 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 so the keep-alive connection stays usable. + CharSequence raw = statusCode.asAsciiCharSequence(); + for (int i = 0, n = raw.length(); i < n; i++) { + char c = raw.charAt(i); + if (c < '0' || c > '9') { + discardBody(body, httpTimeoutMillis); + throw new OidcAuthException("the identity provider returned a malformed HTTP status code"); + } + responseStatus.put(c); + } } jsonLexer.clear(); - Response body = response.getResponse(); try { parseBody(body, jsonLexer, parser, httpTimeoutMillis); } catch (JsonException e) { 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 index 9d928be63..40f532cea 100644 --- 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 @@ -90,6 +90,15 @@ public static MockResponse oversizedJson(long 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 stall() { MockResponse response = new MockResponse(200, "", true); response.stall = true; @@ -254,6 +263,11 @@ private static void writeOversized(OutputStream out, long bodyBytes) throws IOEx } 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; @@ -334,6 +348,7 @@ public static class MockResponse { final int status; boolean dropConnection; long oversizedBodyBytes; + String rawResponse; boolean stall; MockResponse(int status, String body, boolean chunked) { 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 index 14457e8ce..6f94479aa 100644 --- 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 @@ -560,6 +560,47 @@ public void testDeviceFlowHappyPath() throws Exception { }); } + @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(""), true)) { + auth.getToken(); + 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 testDiscoveryDefaultsScopeToOpenid() throws Exception { assertMemoryLeak(() -> { From ddd3e6280167402cf6d850e6827cc8db95e08173 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 21 Jun 2026 23:21:01 +0100 Subject: [PATCH 022/192] Escape control chars in ILP error messages JsonLexer now resolves JSON string escapes, so the message and errorId fields a QuestDB endpoint returns in a JSON error body arrive at the sender fully decoded. The JSON error parser put them into the LineSenderException verbatim, so a hostile or proxied endpoint could inject real control characters or ANSI escapes that forge a log line or rewrite a terminal when the exception text is printed. Render the server-supplied message, id, code and line through putAsPrintable - the same escaping the column-name errors in this class already use - so a decoded control byte arrives escaped. LineHttpSenderErrorResponseTest flushes against a server returning a chunked JSON error whose message and errorId carry an ESC and a newline, and asserts they reach the exception escaped; it fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../line/http/AbstractLineHttpSender.java | 8 +- .../line/LineHttpSenderErrorResponseTest.java | 88 +++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java 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 35841d2d8..f3092d479 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 @@ -1030,16 +1030,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=").put(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(']'); 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..2b08fa305 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java @@ -0,0 +1,88 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/** + * Verifies that the JSON error body a QuestDB HTTP endpoint returns on a failed flush is rendered + * safely into the {@link LineSenderException} message. The JSON lexer resolves string escapes, so a + * {@code message} or {@code errorId} field arrives fully decoded; a hostile or proxied endpoint could + * otherwise smuggle real control characters or ANSI escapes 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. + */ +public class LineHttpSenderErrorResponseTest { + + @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('\u001b') >= 0); + Assert.assertFalse("a raw newline must not leak into the message: " + msg, msg.indexOf('\n') >= 0); + } + } + } + }); + } +} From c0ed8b42a6c37e9c94d6eb0f67ea641e87139982 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 21 Jun 2026 23:29:33 +0100 Subject: [PATCH 023/192] Test the plaintext-channel OIDC pin firing path The plaintext-channel pin refuses /settings-supplied OIDC endpoints fetched over a non-loopback http channel unless the identity provider is pinned out of band, so a tampered response cannot route the device code and refresh token to an attacker. Only its loopback exemption was exercised end to end, because the test mock binds to 127.0.0.1; the firing branch had no integration coverage. Reach the loopback mock through "127.1": the OS resolver expands the short form to 127.0.0.1 so the mock answers, but the loopback classifier deliberately rejects the short form, so the server host is non-loopback and the pin fires. Assert that a plaintext /settings advertising both endpoints without a pin is refused, and that pinning the issuer over the same channel is accepted - proving the pin, not an unrelated rejection, is the gate. The test fails if the firing check is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/cutlass/auth/OidcDeviceAuthTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 index 6f94479aa..56d5a3d63 100644 --- 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 @@ -1597,6 +1597,41 @@ public void testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing() throws Exc } } + @Test(timeout = 30_000) + public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exception { + 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.fromQuestDB(questdbUrl, (String) null, true); + 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, server.httpUrl(""), true)) { + Assert.assertNotNull(auth); + } + } + }); + } + @Test(timeout = 30_000) public void testMalformedEndpointDoesNotLeakNativeMemory() { // build() parses the endpoints up front (for the co-location / issuer-pin checks) and throws on From 8421148550061a9941cbfa84c24473cc7f097ce8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 22 Jun 2026 11:04:03 +0100 Subject: [PATCH 024/192] Fix OIDC Windows test, use try-with-resources Skip testPlaintextSettingsWithAdvertisedEndpointsRequiresPin on Windows: it reaches the loopback mock through the "127.1" short-form address, which Linux/macOS getaddrinfo expands to 127.0.0.1 but Windows getaddrinfo rejects, so discovery cannot connect there. No host string is both reachable at the loopback mock and classified non-loopback on Windows, so the end-to-end firing path cannot run there; the classifier stays covered cross-platform by testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing. Wrap every OidcDeviceAuth construction in try-with-resources so the native JSON lexer and HTTP clients are always released, including the rejection paths where build()/fromQuestDB() throws. Also replace manual StringBuilder fills with String.repeat, switch index loops to enhanced-for, and collapse the split-value test helper to a single lexer cache-limit parameter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/cutlass/auth/OidcDeviceAuthTest.java | 225 +++++++++--------- 1 file changed, 107 insertions(+), 118 deletions(-) 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 index 56d5a3d63..132b2cb9c 100644 --- 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 @@ -33,10 +33,12 @@ import io.questdb.client.cutlass.json.JsonLexer; import io.questdb.client.cutlass.json.JsonParser; 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.TestUtils; import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; import java.lang.reflect.Method; @@ -114,13 +116,15 @@ 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 - OidcDeviceAuth.builder() + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() .clientId("c") .deviceAuthorizationEndpoint("https://idp.example/as/device") .tokenEndpoint("https://idp.example/as/token") .issuer("https://idp.example") .build() - .close(); + ) { + // accepted: build() did not reject the matching-origin endpoints + } }); } @@ -128,13 +132,13 @@ public void testBuilderIssuerPinAcceptsMatchingOrigin() throws Exception { 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.builder() - .clientId("c") - .deviceAuthorizationEndpoint("https://idp.example/device") - .tokenEndpoint("https://idp.example/token") - .issuer("https://other-idp.example") - .build(); + 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")); @@ -143,20 +147,17 @@ public void testBuilderIssuerPinRejectsOffOriginEndpoints() { @Test(timeout = 30_000) public void testBuilderRejectsMissingRequiredOptions() { - try { - OidcDeviceAuth.builder().deviceAuthorizationEndpoint("https://h/d").tokenEndpoint("https://h/t").build(); + 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.builder().clientId("c").tokenEndpoint("https://h/t").build(); + 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.builder().clientId("c").deviceAuthorizationEndpoint("https://h/d").build(); + 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")); @@ -167,12 +168,12 @@ public void testBuilderRejectsMissingRequiredOptions() { 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.builder() - .clientId("c") - .deviceAuthorizationEndpoint("https://device.example/device") - .tokenEndpoint("https://token.example/token") - .build(); + 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")); @@ -338,11 +339,7 @@ public void testChallengeStripsSupplementaryPlaneFormatChars() throws Exception public void testChunkedTokenResponseParses() throws Exception { assertMemoryLeak(() -> { // real IdPs use Transfer-Encoding: chunked; a multi-KB id token split across chunks must parse - StringBuilder bigToken = new StringBuilder(); - for (int i = 0; i < 3000; i++) { - bigToken.append('a'); - } - String idToken = bigToken.toString(); + String idToken = "a".repeat(3000); MockOidcServer.Handler handler = (method, path, body) -> { if (DEVICE_PATH.equals(path)) { return MockOidcServer.chunkedJson(200, deviceAuthorizationJson(1, 300)); @@ -691,8 +688,7 @@ public void testDiscoveryRejectsMissingClientId() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { Assert.fail("expected discovery to fail"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("client id")); @@ -716,8 +712,7 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { Assert.fail("expected discovery to fail"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint")); @@ -739,8 +734,7 @@ public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Except } // closed now - nothing listens on deadPort long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); long clientMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); - try { - OidcDeviceAuth.fromQuestDB("http://127.0.0.1:" + deadPort, true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB("http://127.0.0.1:" + deadPort, true)) { 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")); @@ -794,12 +788,12 @@ public void testEndpointParseRejectsDisplayUnsafeUrl() { }; for (int i = 0; i < unsafe.length; i++) { String marker = unsafe[i]; - try { - OidcDeviceAuth.builder() - .clientId("c") - .deviceAuthorizationEndpoint("https://idp.example/dev" + marker + "ice") - .tokenEndpoint("https://idp.example/t") - .build(); + 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")); @@ -998,8 +992,7 @@ public void testFromQuestDbDiscoveryDocMissingDeviceEndpointRejected() throws Ex }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), server.httpUrl(""), true)) { Assert.fail("expected discovery to fail"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device_authorization_endpoint")); @@ -1090,8 +1083,7 @@ public void testFromQuestDbDiscoveryUrlPinRejectsForeignIssuerInDocument() throw "https://attacker.example")); }; try (MockOidcServer server = new MockOidcServer(handler)) { - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true)) { Assert.fail("expected the discoveryUrl pin to reject a document declaring a foreign issuer"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origin than the pinned discovery url")); @@ -1113,8 +1105,7 @@ public void testFromQuestDbDiscoveryUrlPinRejectsOffOriginAdvertisedEndpoints() }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, "https://trusted-idp.example/.well-known/openid-configuration", null, true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, "https://trusted-idp.example/.well-known/openid-configuration", null, true)) { Assert.fail("expected the discoveryUrl pin to reject the off-origin endpoints"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); @@ -1136,8 +1127,7 @@ public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), "https://idp.attacker.example", true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), "https://idp.attacker.example", true)) { Assert.fail("expected the issuer pin to reject the off-origin endpoints"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); @@ -1161,8 +1151,7 @@ public void testFromQuestDbRejectsCrlfInjectedAdvertisedEndpoint() throws Except }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { Assert.fail("expected the CR/LF-injected token endpoint to be rejected"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("illegal character")); @@ -1176,8 +1165,7 @@ 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.fromQuestDB("http://questdb.example:9000"); + 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")); @@ -1196,8 +1184,7 @@ public void testFromQuestDbRejectsMissingDeviceEndpoint() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { Assert.fail("expected discovery to fail"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint")); @@ -1214,8 +1201,7 @@ public void testFromQuestDbRejectsOidcDisabled() throws Exception { MockOidcServer.json(200, settingsJson(false, false, serverRef.get().httpUrl(TOKEN_PATH), null)); try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { Assert.fail("expected discovery to fail"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("OIDC is not enabled")); @@ -1492,36 +1478,38 @@ public void testIncompleteDeviceResponseRejected() throws Exception { public void testInsecureEndpointsRejectedUnlessOptedIn() throws Exception { assertMemoryLeak(() -> { // http endpoints carry tokens in cleartext; the client must refuse them unless the caller opts in - try { - OidcDeviceAuth.builder() - .clientId("c") - .deviceAuthorizationEndpoint("http://idp.example/device") - .tokenEndpoint("https://idp.example/token") - .build(); + 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.builder() - .clientId("c") - .deviceAuthorizationEndpoint("https://idp.example/device") - .tokenEndpoint("http://idp.example/token") - .build(); + 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")); } // opting in allows http, for local development - OidcDeviceAuth.builder() + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() .clientId("c") .deviceAuthorizationEndpoint("http://idp.example/device") .tokenEndpoint("http://idp.example/token") .allowInsecureTransport(true) .build() - .close(); + ) { + // accepted: http endpoints are allowed once insecure transport is opted in + } }); } @@ -1533,24 +1521,20 @@ public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exc // 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". - StringBuilder value = new StringBuilder(); - for (int i = 0; i < 4000; i++) { - value.append('a'); - } - String json = "{\"id_token\":\"" + value + "\"}"; + String json = "{\"id_token\":\"" + "a".repeat(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, 1024, address, split, len); + 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(1024, 1 << 20, address, split, len); + parseSplitValue(1 << 20, address, split, len); } finally { Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT); } @@ -1566,8 +1550,8 @@ public void testLoopbackHostClassifierAcceptsLoopbackForms() throws Exception { "localhost", "LOCALHOST", "LocalHost", "127.0.0.1", "127.0.0.0", "127.1.2.3", "127.255.255.255", "127.0.0.255" }; - for (int i = 0; i < loopback.length; i++) { - Assert.assertTrue("expected loopback: [" + loopback[i] + "]", invokeIsLoopbackHost(loopback[i])); + for (String s : loopback) { + Assert.assertTrue("expected loopback: [" + s + "]", invokeIsLoopbackHost(s)); } } @@ -1592,13 +1576,17 @@ public void testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing() throws Exc "227.0.0.1", // not the 127 block "0.0.0.0", "10.0.0.1", "192.168.0.1", "::1" }; - for (int i = 0; i < notLoopback.length; i++) { - Assert.assertFalse("expected non-loopback: [" + notLoopback[i] + "]", invokeIsLoopbackHost(notLoopback[i])); + for (String s : notLoopback) { + Assert.assertFalse("expected non-loopback: [" + s + "]", invokeIsLoopbackHost(s)); } } @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 @@ -1616,8 +1604,7 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc 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.fromQuestDB(questdbUrl, (String) null, true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(questdbUrl, (String) null, true)) { 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")); @@ -1639,13 +1626,13 @@ public void testMalformedEndpointDoesNotLeakNativeMemory() { // instance cannot leak it. Measure the parser tag directly - the module's assertMemoryLeak does not // flag a single-tag growth. long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); - try { - OidcDeviceAuth.builder() - .clientId("c") - .deviceAuthorizationEndpoint("not-a-url") - .tokenEndpoint("https://idp.example/token") - .allowInsecureTransport(true) - .build(); + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("not-a-url") + .tokenEndpoint("https://idp.example/token") + .allowInsecureTransport(true) + .build() + ) { Assert.fail("expected Endpoint.parse to reject the malformed url"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("expected a scheme")); @@ -1854,8 +1841,7 @@ public void testOversizedSettingsBodyAbortsAtSizeCap() throws Exception { // 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.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { 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 @@ -2334,8 +2320,7 @@ public void testTruncatedSettingsResponseRejected() throws Exception { 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.fromQuestDB(server.httpUrl(""), true); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { Assert.fail("expected discovery to reject the truncated settings body"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not parse")); @@ -2418,28 +2403,32 @@ public void testUseAfterCloseThrowsClearly() { // calling getToken()/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); - OidcDeviceAuth auth = OidcDeviceAuth.builder() + // 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(); - try { - auth.getToken(); - Assert.fail("expected getToken() after close() to be rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); - } - try { - auth.clearCache(); - Assert.fail("expected clearCache() after close() to be rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); + .build() + ) { + auth.close(); + try { + auth.getToken(); + Assert.fail("expected getToken() after close() to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); + } + try { + auth.clearCache(); + Assert.fail("expected clearCache() after close() to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); + } + // getToken() 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)); } - // getToken() 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) @@ -2509,12 +2498,12 @@ public void testWrongTokenKindDoesNotWedgeCache() throws Exception { } private static void assertBuildFails(String deviceEndpoint, String tokenEndpoint, String expectedMessage) { - try { - OidcDeviceAuth.builder() - .clientId("c") - .deviceAuthorizationEndpoint(deviceEndpoint) - .tokenEndpoint(tokenEndpoint) - .build(); + 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)); @@ -2588,8 +2577,8 @@ private static DeviceCodePrompt noopPrompt() { }; } - private static void parseSplitValue(int cacheSize, int cacheSizeLimit, long address, int split, int len) throws JsonException { - try (JsonLexer lexer = new JsonLexer(cacheSize, cacheSizeLimit)) { + 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(); From 49becd9a9ec6c7ff7537b4b384f35dd941ad330f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 22 Jun 2026 11:28:10 +0100 Subject: [PATCH 025/192] Reject OIDC tokens with control or non-ASCII chars A token whose JSON value carries an escaped CR/LF now decodes to real control bytes (the lexer resolves string escapes), and getToken() serves it verbatim as an "Authorization: Bearer " header value and as the PG-wire _sso password. A control character would break out of the header and inject into the request line sent to the trusted QuestDB server; a non-ASCII character is silently truncated by the ASCII header writer. storeTokens now validates the access and id tokens and rejects any character outside printable ASCII (0x20-0x7E) before caching them, so a tampered or corrupt credential from a hostile or man-in-the-middled identity provider never reaches the wire. The refresh token is left unchecked: it is only ever sent URL-encoded. The token bytes are never embedded in the error message. Add testTokenWithControlCharsRejected, which fails without the guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 23 +++++++++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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 index f0dc3cb1a..b91cee508 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -776,6 +776,24 @@ private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint dev } } + private static void validateTokenChars(CharSequence token, String tokenName) { + // The selected token is written verbatim into the "Authorization: Bearer " header sent to the + // trusted QuestDB server, and used as the PG-wire _sso password. A CR/LF or other control character + // would break out of the header and inject into the request line - the JSON lexer now decodes a \r or + // \n escape in the identity provider's response into a real control byte - and a non-ASCII character + // is silently truncated to one byte by the ASCII header writer. A real OAuth token is printable ASCII, + // so reject anything outside that range rather than route a tampered or corrupt credential onto the + // wire. The token bytes are never embedded in the message: they are the secret this class protects. + for (int i = 0, n = token.length(); i < n; i++) { + char c = token.charAt(i); + if (c < 0x20 || c > 0x7e) { + 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) == '/') { @@ -1022,6 +1040,11 @@ private void sleepBetweenPolls(long millis) { } private void storeTokens(TokenResponseParser parser) { + // reject a token carrying control or non-ASCII characters before caching it: 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 + validateTokenChars(parser.accessToken, "access_token"); + validateTokenChars(parser.idToken, "id_token"); accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null; idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null; // a refresh response usually omits a new refresh token, in that case we keep the current one 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 index 132b2cb9c..ec15e6ce7 100644 --- 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 @@ -2288,6 +2288,34 @@ public void testTokenUnderNonSuccessStatusIsNotAccepted() throws Exception { }); } + @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())) { + try { + auth.getToken(); + Assert.fail("expected a token with control characters to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); + // the token bytes must never leak into the message + Assert.assertFalse(e.getMessage(), e.getMessage().contains("X-Injected")); + } + } + }); + } + @Test(timeout = 30_000) public void testTransientParseFailureDuringPollingRecovers() throws Exception { assertMemoryLeak(() -> { From bd37dc8ca0760255e15cd2ff99e07688b81c206e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 22 Jun 2026 11:51:26 +0100 Subject: [PATCH 026/192] Bound chunked response reads to the call timeout AbstractChunkedResponse.recv re-armed the full timeout on every internal read while scanning an incomplete chunk-size line, so a server that dribbles that line one byte per timeout window - or fills the buffer with a CRLF-less chunk size - kept a single recv() running without bound. That defeats a caller's wall-clock deadline, e.g. OidcDeviceAuth.parseBody, whose comment claims a dribbling server cannot wedge the thread. recv(int) now bounds the whole call to the given timeout when it is positive: it tracks elapsed time, shrinks the per-read budget, and throws once the budget is exhausted. The first read still gets the full budget; a non-positive timeout keeps the legacy unbounded behaviour, so the existing test harness is unaffected. The Response.recv javadoc is updated to match. Add testRecvHonoursTotalTimeoutWhileChunkSizeDribbles, which hangs and trips its JUnit timeout without the bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../http/client/AbstractChunkedResponse.java | 16 +++++++++- .../client/cutlass/http/client/Response.java | 6 ++-- .../http/client/ChunkedResponseTest.java | 32 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) 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..f82c12626 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 @@ -91,10 +91,24 @@ public long lo() { } public Fragment recv(int timeout) { + // When a positive timeout is given, bound the whole call to it, not each socket read. This loop keeps + // re-reading while a chunk-size line (or the chunk-data-end CRLF) is still incomplete, so without one + // shared deadline a server that dribbles those bytes - one per timeout window - would keep a single + // recv() running for (line length) x timeout and defeat a caller's wall-clock 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) { if (receive || dataLo == dataHi) { compactBuffer(); - dataHi += 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 chunked response body"); + } + } + dataHi += recvOrDie(dataHi, bufHi, callTimeout); } long p; // moving data pointer for scanning buffer switch (state) { 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 2a0992663..c3a337e1d 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 @@ -36,8 +36,10 @@ public interface Response { Fragment recv(); /** - * Receives the next fragment of response data, blocking at most {@code timeout} milliseconds for - * a socket read. + * Receives the next fragment of response data. When {@code timeout} is positive it bounds the whole + * call to at most {@code timeout} milliseconds in total (not per socket read), so a server that + * dribbles the body one byte at a time cannot keep a single call running past it; a non-positive + * {@code timeout} disables the bound. * * @param timeout the receive timeout in milliseconds * @return the received fragment, or null once the body has been fully read 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..98b9f7af1 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,37 @@ public void testFuzz() { createChunks(rnd, encoded.toString(), fragCount)); } + @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 = { From 64933dcee7b7f572641a0a28857a16aca3f4f2b8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 22 Jun 2026 12:37:59 +0100 Subject: [PATCH 027/192] Escape bidi and format chars in error messages putAsPrintable rendered untrusted text - an ILP server's JSON error body, a column name - into a LineSenderException message escaping only C0 controls and DEL. Bidi overrides, zero-width joiners and the BOM passed through raw, so a hostile or proxied endpoint (whose JSON escapes the lexer now decodes to real code points) could reorder or hide the text a human reads in a terminal or a log line. It also truncated any escaped char above U+00FF to its low byte. putAsPrintable now escapes control characters and Unicode format characters, matching the OIDC display sanitizer's threat model, and emits the full four hex digits. Escaping rather than stripping keeps the original visible for diagnosis. For characters up to U+00FF the output is unchanged. This is the client's own Utf16Sink copy. Also close OIDC test-coverage gaps: - reject a malformed status code on the token-poll path, not only the device-authorization path - getTokenSilently fails fast while another thread holds the lock in a silent refresh, not only an interactive sign-in - a backslash-u escape split across parse() fragments still decodes - tighten the stalled-body timeout assertion to prove the configured 1s limit fired Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/std/str/Utf16Sink.java | 20 ++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 104 +++++++++++++++++- .../test/cutlass/json/JsonLexerTest.java | 30 +++++ .../line/LineHttpSenderErrorResponseTest.java | 39 +++++++ 4 files changed, 184 insertions(+), 9 deletions(-) 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..3e07e250f 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 @@ -52,17 +52,23 @@ default void putAsPrintable(CharSequence nonPrintable) { } default void putAsPrintable(char c) { - if (c > 0x1F && c != 0x7F) { + // escape control characters (C0/C1 and DEL) and Unicode "format" characters - the bidi + // embeddings/overrides/isolates, the LRM/RLM marks, zero-width joiners and the BOM - to a visible + // \\uXXXX. Left raw, attacker-influenced text (an ILP server's JSON error body, a column name) could + // reorder, hide or forge what a human reads in a terminal or a log line; escaping rather than + // stripping keeps the original visible for diagnosis. Scanning per UTF-16 unit covers every BMP + // threat; a legitimate supplementary-plane char (an emoji surrogate pair) is neither a control nor a + // format character and passes through unchanged. The full four hex digits are emitted, so a format + // char above U+00FF (e.g. U+202E) renders correctly rather than truncated to its low byte. + if (!Character.isISOControl(c) && Character.getType(c) != Character.FORMAT) { put(c); } else { put('\\'); put('u'); - - final int s = (int) c & 0xFF; - put('0'); - put('0'); - put(hexDigits[s / 0x10]); - put(hexDigits[s % 0x10]); + put(hexDigits[(c >> 12) & 0xF]); + put(hexDigits[(c >> 8) & 0xF]); + put(hexDigits[(c >> 4) & 0xF]); + put(hexDigits[c & 0xF]); } } 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 index ec15e6ce7..ab5ce73dd 100644 --- 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 @@ -598,6 +598,38 @@ public void testNonNumericStatusCodeRejected() throws Exception { }); } + @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())) { + try { + auth.getToken(); + Assert.fail("expected a malformed status code on the poll path 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 testDiscoveryDefaultsScopeToOpenid() throws Exception { assertMemoryLeak(() -> { @@ -1291,6 +1323,71 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc }); } + @Test(timeout = 30_000) + public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Exception { + assertMemoryLeak(() -> { + // the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not + // just an interactive sign-in: getTokenSilently() must fail fast rather than queue behind it. A high + // clock skew keeps the cached token permanently "expired", so getTokenSilently() always refreshes; + // the token endpoint blocks the refresh response until the test releases it, pinning the lock on the + // refresher thread while the second caller races for it + CountDownLatch refreshInFlight = new CountDownLatch(1); + CountDownLatch releaseRefresh = new CountDownLatch(1); + 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 { + releaseRefresh.await(20, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 1)); + } + 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) + .clockSkewSeconds(3600) // keep the cached token always "expired" so a refresh runs + .prompt(noopPrompt()) + .build()) { + auth.getToken(); // sign in once: caches ACCESS-1 and a refresh token + Thread refresher = new Thread(() -> { + try { + auth.getTokenSilently(); + } catch (Throwable ignore) { + // the refresh completes once released; a late error here is irrelevant to this test + } + }, "oidc-silent-refresh"); + refresher.setDaemon(true); + refresher.start(); + try { + Assert.assertTrue("the silent refresh did not start", refreshInFlight.await(10, TimeUnit.SECONDS)); + // a refresh holds the lock now; getTokenSilently() on this thread must fail fast, not block + long startNanos = System.nanoTime(); + try { + auth.getTokenSilently(); + Assert.fail("expected getTokenSilently() to fail fast while a refresh is in progress"); + } catch (OidcAuthException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + Assert.assertTrue("getTokenSilently() blocked " + elapsedMillis + "ms behind the in-flight refresh", + elapsedMillis < 2_000); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); + } + } finally { + releaseRefresh.countDown(); + refresher.join(10_000); + } + } + }); + } + @Test(timeout = 30_000) public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { assertMemoryLeak(() -> { @@ -2122,8 +2219,11 @@ public void testStalledResponseBodyAbortsWithinTimeout() throws Exception { Assert.fail("expected the stalled body read to abort"); } catch (OidcAuthException e) { long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; - // aborted on the ~1s OIDC timeout, not the 600s HttpClient default (or an indefinite wedge) - Assert.assertTrue("aborted too slowly: " + elapsedMillis + "ms", elapsedMillis < 10_000); + // 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); } } }); 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 9e781b715..74d8d9d5b 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 @@ -711,6 +711,36 @@ public void testStringEscapesDecodedAcrossSplitParseCalls() throws Exception { }); } + @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 testStringEscapesExoticAndLenient() throws Exception { assertMemoryLeak(() -> { 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 index 2b08fa305..c3256cff0 100644 --- 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 @@ -42,6 +42,45 @@ */ public class LineHttpSenderErrorResponseTest { + @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 testServerJsonErrorControlCharsAreEscaped() throws Exception { assertMemoryLeak(() -> { From 619a3fc426344f8d264ce13c1e112b358b92b69e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 22 Jun 2026 13:07:44 +0100 Subject: [PATCH 028/192] Tighten OIDC device-flow comments and javadoc Condense the verbose comments and javadoc the device-flow PR added, across the new auth classes (OidcDeviceAuth, OidcAuthException, DeviceAuthorizationChallenge, DeviceCodePrompt, HttpTokenProvider) and the comments added to JsonLexer, Response, Utf16Sink, AbstractChunkedResponse, AbstractLineHttpSender and Sender. Drop filler, use active voice, and collapse wrapped lines while preserving every technical fact - the security rationale, RFC references, invariants, and ordering/locking notes. Comments only; no code changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 16 +- .../main/java/io/questdb/client/Sender.java | 20 +- .../auth/DeviceAuthorizationChallenge.java | 17 +- .../client/cutlass/auth/DeviceCodePrompt.java | 18 +- .../cutlass/auth/OidcAuthException.java | 41 +- .../client/cutlass/auth/OidcDeviceAuth.java | 480 +++++++++--------- .../http/client/AbstractChunkedResponse.java | 10 +- .../client/cutlass/http/client/Response.java | 7 +- .../client/cutlass/json/JsonLexer.java | 14 +- .../line/http/AbstractLineHttpSender.java | 42 +- .../io/questdb/client/std/str/Utf16Sink.java | 15 +- 11 files changed, 322 insertions(+), 358 deletions(-) diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index 3e540320a..9a23f8925 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -26,21 +26,21 @@ /** * Supplies an HTTP authentication token to a {@link Sender} on demand. The sender calls - * {@link #getToken()} as it builds each request, so a provider that returns a freshly refreshed - * token - for example {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender - * authenticated as the token rotates, without rebuilding the sender. + * {@link #getToken()} as it builds each request, so a provider returning a freshly refreshed token + * - e.g. {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender authenticated as the + * token rotates, without rebuilding it. *

- * {@link #getToken()} runs on the sender's flush path, so it must return promptly and must not - * block on interactive input. It may perform a quick silent token refresh, but must not start an - * interactive sign-in. An exception thrown from {@link #getToken()} fails the current flush. + * {@link #getToken()} runs on the sender's flush path: it must return promptly and must not block on + * interactive input. A quick silent token refresh is fine, but it must not start an interactive + * sign-in. An exception from {@link #getToken()} fails the current flush. * * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider) */ @FunctionalInterface public interface HttpTokenProvider { /** - * Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the - * sender adds it). Must not return null or an empty value. + * Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the sender + * adds it). Must not return null or empty. * * @return the current HTTP authentication token */ diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index eeac08208..26f0c8a55 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2010,18 +2010,16 @@ public LineSenderBuilder httpToken(String token) { } /** - * Supplies the HTTP authentication token from a provider that the sender queries as it builds - * each request, instead of a fixed {@link #httpToken(String) token} captured once. This keeps a - * long-lived sender following token refreshes - for example a token obtained through the OIDC - * device flow: {@code .httpTokenProvider(auth::getTokenSilently)}. + * 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::getTokenSilently)}. *
- * The sender does not call the provider at build time: the first call happens when the first row - * is started, then once per flush. A provider that signs in lazily can therefore be wired before - * the interactive sign-in completes, as long as a token is obtainable before the first row is - * added - otherwise that first row fails. The provider runs on the flush path, so it must return - * promptly and must not block on interactive input (see {@link HttpTokenProvider}). Only valid for - * HTTP transport, and mutually exclusive with {@link #httpToken(String)} and - * {@link #httpUsernamePassword(String, String)}. + * The provider is not called at build time: the first call happens when the first row is started, + * then once per flush. A lazily-signing-in provider can therefore be wired before the interactive + * sign-in completes, as long as a token is obtainable before the first row - otherwise that row + * fails. Running on the flush path, the provider must return promptly and must not block on + * interactive input (see {@link HttpTokenProvider}). HTTP transport only, 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 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 index 5235fa1d5..f398ebfd1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java @@ -25,9 +25,8 @@ package io.questdb.client.cutlass.auth; /** - * The user-facing part of an RFC 8628 device authorization response: the code the - * user has to type and the URL where they type it. A {@link DeviceCodePrompt} - * receives this object and is responsible for showing it to the user. + * 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. @@ -54,36 +53,36 @@ public DeviceAuthorizationChallenge( } /** - * @return how long, in seconds, the {@link #getUserCode() user code} stays valid. + * @return seconds the {@link #getUserCode() user code} stays valid. */ public int getExpiresInSeconds() { return expiresInSeconds; } /** - * @return the minimum number of seconds the client must wait between polls. + * @return minimum seconds the client must wait between polls. */ public int getIntervalSeconds() { return intervalSeconds; } /** - * @return the code the user has to enter at the {@link #getVerificationUri() verification URL}. + * @return the code the user enters at the {@link #getVerificationUri() verification URL}. */ public String getUserCode() { return userCode; } /** - * @return the URL the user has to open to authorize the device. + * @return the URL the user opens to authorize the device. */ public String getVerificationUri() { return verificationUri; } /** - * @return a URL that already embeds the user code, so the user does not have to type it, - * or {@code null} when the identity provider does not supply one. + * @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 index 184d09822..c08eebd18 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java @@ -27,20 +27,18 @@ import io.questdb.client.std.str.StringSink; /** - * Shows an RFC 8628 device authorization challenge to the user, who then opens the - * verification URL in any browser (on the same machine or on a phone) and enters the - * code. {@link OidcDeviceAuth} calls this once per interactive sign-in, just before it - * starts polling the token endpoint. + * 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 {@link #SYSTEM_OUT default implementation} prints the instructions to - * {@code System.out}. Supply your own implementation to render the challenge somewhere - * else, for example as a clickable link or a QR code in a notebook. + * The {@link #SYSTEM_OUT default implementation} prints instructions to {@code System.out}. 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} using plain ASCII text. + * Prints the sign-in instructions to {@code System.out} as plain ASCII. */ DeviceCodePrompt SYSTEM_OUT = challenge -> { String newLine = System.lineSeparator(); @@ -59,8 +57,8 @@ public interface DeviceCodePrompt { }; /** - * Shows the challenge to the user. This method must return quickly; the actual waiting - * for the user happens afterwards while {@link OidcDeviceAuth} polls the token endpoint. + * 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 */ 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 index b0a6467e1..1ccd6dbee 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -27,12 +27,11 @@ import io.questdb.client.std.str.StringSink; /** - * Thrown when the OIDC device authorization flow cannot obtain a token. The message is built - * with the fluent {@link #put(CharSequence)} family, backed by a {@link 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}. *

- * When the failure originates from an OAuth error response (RFC 6749 / RFC 8628), - * {@link #getOauthError()} returns the machine-readable error code (for example - * {@code access_denied} or {@code expired_token}); otherwise it returns {@code null}. + * 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(); @@ -50,7 +49,7 @@ public OidcAuthException(Throwable cause) { } /** - * Builds an exception out of an OAuth error response. + * 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 @@ -67,20 +66,16 @@ public static OidcAuthException oauthError(CharSequence error, CharSequence desc return e; } - // Reports characters that must never reach a terminal or a log line. The parameter is a Unicode code - // point, not a UTF-16 unit: the callers (sanitizeForDisplay / putSanitized) scan with codePointAt, which - // reassembles a valid high+low surrogate pair - the form a supplementary-plane char arrives in after the - // JSON lexer emits each backslash-u-XXXX escape verbatim - into one code point, so a supplementary-plane format - // or control char is judged as one character rather than as two surrogate halves that each look harmless - // (the gap that let an invisible U+E00xx "tag" char slip through). An unpaired surrogate (a lone half the - // lexer never reassembled) surfaces from codePointAt as a SURROGATE code point and is stripped too, as it - // carries no displayable meaning. - // Beyond the C0/C1 controls and DEL that isISOControl covers, this strips the Unicode "format" - // category (Cf) - zero-width joiners, the byte-order mark, the bidirectional embedding/override/isolate - // controls, and the U+E00xx tag characters - plus an explicit bidi/BOM set, so an attacker-influenced - // value (a verification_uri, a user_code, an error string) cannot reorder, hide, or spoof the text a - // human reads, even on a JDK whose Unicode tables categorize these differently. Hex literals (not char - // escapes) keep this source strictly ASCII, so the file itself carries none of the chars it guards against. + // Reports characters that must never reach a terminal or log line. 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, having no displayable meaning. + // Beyond the C0/C1 controls and DEL from isISOControl, this strips the Unicode format category (Cf: + // zero-width joiners, BOM, bidi embedding/override/isolate controls, U+E00xx tag chars) plus an + // explicit bidi/BOM set, so an attacker-influenced value (verification_uri, user_code, error string) + // cannot reorder, hide, or spoof displayed text - even on a JDK that categorizes these differently. + // Hex literals (not char escapes) keep this source ASCII, so it carries none of the chars it guards. static boolean isUnsafeForDisplay(int c) { return Character.isISOControl(c) || Character.getType(c) == Character.FORMAT @@ -115,9 +110,9 @@ public OidcAuthException put(long value) { return this; } - // appends untrusted text with display-unsafe characters 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 + // 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; ) { 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 index b91cee508..f85bebe75 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -50,13 +50,12 @@ import java.util.concurrent.locks.ReentrantLock; /** - * Obtains an OIDC access or id token using the OAuth 2.0 Device Authorization Grant - * (RFC 8628), so a process with no local browser (a remote notebook kernel, a container, - * a headless job) can still sign a human in. The user authorizes on any device, while the - * token request travels outbound only. + * 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 resulting token can be presented to QuestDB Enterprise over any of the auth paths - * the server already validates: + * 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});
  • @@ -80,54 +79,50 @@ * .groupsInToken(true) * .build(); * } - * {@link #getToken()} returns a cached token while it is still valid, silently refreshes it - * when a refresh token is available, and otherwise re-runs the interactive flow. Calls are - * serialized on an instance lock, so concurrent callers never start two sign-ins at once. A - * sign-in waiting for the user holds that lock for the lifetime of the device code (up to an - * hour), so a concurrent {@link #getToken()} or {@link #clearCache()} call on the same instance - * blocks behind it - but {@link #getTokenSilently()} does not: it never waits for an in-flight - * sign-in, it fails fast with an {@link OidcAuthException}, so a request/flush path is never - * stalled. To abort a sign-in that is waiting, call {@link #close()} from another thread: it - * signals the in-flight flow to stop, which then fails with an {@link OidcAuthException} rather - * than polling on until the device code expires. Cancellation is observed between polls (within - * about 100ms while a poll interval is being waited out); a poll request already in flight is not - * interrupted mid-request, so the abort - and {@link #close()} itself - can take up to one HTTP - * request timeout (see {@link Builder#httpTimeoutMillis(int)}), still far short of the device-code - * lifetime. + * {@link #getToken()} 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 an hour), so a concurrent {@link #getToken()} or {@link #clearCache()} blocks + * behind it - but {@link #getTokenSilently()} never waits: it fails fast with an + * {@link OidcAuthException} so a request/flush path never stalls. 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. *

    - * Instances are interactive by design and hold a network connection; close them when done. - * Token state lives in memory only and does not survive a restart of the process. + * Instances are interactive and hold a network connection; close them when done. Token state is + * in-memory only and does not survive a process restart. */ 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"; private static final int DEFAULT_CLOCK_SKEW_SECONDS = 30; - // how long the device code stays valid for the interactive sign-in when the identity provider's - // device authorization response omits expires_in + // device code TTL when the device authorization response omits expires_in private static final int DEFAULT_DEVICE_CODE_TTL_SECONDS = 300; private static final int DEFAULT_HTTP_TIMEOUT_MILLIS = 30_000; private static final int DEFAULT_POLL_INTERVAL_SECONDS = 5; - // how long a token is cached before getToken() refreshes it, when the token response omits expires_in + // token cache TTL when the token response omits expires_in private static final int DEFAULT_TOKEN_TTL_SECONDS = 300; private static final String ERROR_AUTHORIZATION_PENDING = "authorization_pending"; private static final String ERROR_SLOW_DOWN = "slow_down"; private static final HttpClientConfiguration HTTP_CONFIG = DefaultHttpClientConfiguration.INSTANCE; - // Token responses carry JWTs - an id token with group claims can be several KB - and a single - // value may arrive split across HTTP response fragments. The JSON lexer stashes a split value - // and rejects it once it grows past JSON_LEXER_MAX_VALUE_BYTES, so the limit must comfortably - // exceed any real token, otherwise large tokens fail to parse with "String is too long". + // 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; - // a persistent transport failure while polling aborts after this many consecutive attempts, - // instead of silently retrying until the device code expires + // abort polling after this many consecutive transport failures instead of silently retrying + // until the device code expires private static final int MAX_CONSECUTIVE_POLL_ERRORS = 3; - // upper bounds on the expires_in / interval the identity provider reports, so an absurd or - // hostile value cannot overflow the poll timing arithmetic or make the client wait absurdly long + // upper bounds on the provider-reported expires_in / interval, so an absurd or hostile value + // cannot overflow the poll timing arithmetic or make the client wait absurdly long private static final int MAX_EXPIRES_IN_SECONDS = 3600; private static final int MAX_POLL_INTERVAL_SECONDS = 300; - // cap the bytes drained from a single response so a hostile or MITM'd server cannot stream an endless - // body and wedge the thread; set far above any real OIDC JSON response + // 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; private static final int POLL_PENDING = 1; private static final long POLL_SLEEP_SLICE_MILLIS = 100; @@ -146,8 +141,7 @@ public class OidcDeviceAuth implements QuietCloseable { private final boolean groupsInToken; private final int httpTimeoutMillis; // serializes getToken()/getTokenSilently()/clearCache()/close(); getToken() holds it for the whole - // interactive flow, getTokenSilently() acquires it without blocking (tryLock) so the flush path is - // never stalled behind an in-flight sign-in + // interactive flow, getTokenSilently() 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(); @@ -175,8 +169,8 @@ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { this.clockSkewMillis = builder.clockSkewSeconds * 1000L; this.prompt = builder.prompt; this.tlsConfig = tlsConfig; - // allocate the native JSON lexer last: an Endpoint.parse above can throw on a malformed url, - // and the half-built instance is never returned, so close() could not free an earlier alloc + // allocate the native lexer last: an Endpoint.parse above can throw on a malformed url, 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); } @@ -185,19 +179,17 @@ public static Builder builder() { } /** - * Discovers the OIDC configuration from a running QuestDB server and builds an instance - * around it. Reads the public {@code /settings} endpoint (no auth required) and picks up - * the client id, scope, token endpoint, device authorization endpoint and the - * groups-in-token mode the server expects. + * 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 token and device authorization endpoints the user signs in against are - * taken from the server's unauthenticated {@code /settings} response. A spoofed, compromised, or - * man-in-the-middled server can therefore redirect the entire sign-in to an attacker-controlled - * identity provider and harvest the user's authorization. Only call {@code fromQuestDB} against a - * server you trust, reached over {@code https} (required by default; relaxing it with - * {@link Builder#allowInsecureTransport(boolean)} removes the transport protection). When the - * server is not trusted, configure the identity provider explicitly with {@link #builder()}, - * or pin it with {@link #fromQuestDB(String, String)}. + * 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 with {@link #fromQuestDB(String, String)}. * * @param questdbUrl the QuestDB HTTP base URL, for example {@code https://questdb.example.com:9000} * @return a configured, ready-to-use instance @@ -209,26 +201,24 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl) { } /** - * Same as {@link #fromQuestDB(String)} but lets the caller permit insecure {@code http} transport - * for the QuestDB server and the discovered identity provider endpoints (see - * {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. + * Like {@link #fromQuestDB(String)} but permits insecure {@code http} for the server and the + * discovered identity provider endpoints (see {@link Builder#allowInsecureTransport(boolean)}). + * Local development only. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, boolean allowInsecureTransport) { return fromQuestDB(questdbUrl, null, null, defaultTlsConfig(), allowInsecureTransport); } /** - * Same as {@link #fromQuestDB(String)} but pins the identity provider by its {@code issuer} origin + * Like {@link #fromQuestDB(String)} but pins the identity provider by its {@code issuer} origin * (for example {@code https://idp.example.com}). The issuer serves two roles: *

      *
    • when the server does not advertise the device authorization endpoint (today's servers, - * and older ones), it is discovered from the issuer's {@code .well-known/openid-configuration} - * document; the discovery origin is taken only from this out-of-band issuer, never from a value - * the server's {@code /settings} supplied, so a tampered {@code /settings} cannot choose where - * the credentials are sent;
    • - *
    • it pins the token and device authorization endpoints: either endpoint that does not belong - * to the issuer origin is rejected, so a compromised-but-TLS-valid server cannot redirect the - * sign-in to an attacker.
    • + * and older ones), 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}, so + * a tampered {@code /settings} cannot choose where credentials are sent; + *
    • it pins the token and device authorization endpoints: any endpoint not on the issuer + * origin is rejected, so a compromised-but-TLS-valid server cannot redirect the sign-in.
    • *
    */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer) { @@ -236,35 +226,35 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer) { } /** - * Same as {@link #fromQuestDB(String, String)} but lets the caller permit insecure {@code http} - * transport for the QuestDB server and the discovered identity provider endpoints (see - * {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. + * Like {@link #fromQuestDB(String, String)} but permits insecure {@code http} for the server and + * the discovered identity provider endpoints (see {@link Builder#allowInsecureTransport(boolean)}). + * Local development only. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, boolean allowInsecureTransport) { return fromQuestDB(questdbUrl, issuer, null, defaultTlsConfig(), allowInsecureTransport); } /** - * Same as {@link #fromQuestDB(String)} but with an explicit TLS configuration, used for the - * discovery request, any identity provider discovery document, and the later sign-in requests. + * Like {@link #fromQuestDB(String)} but with an explicit TLS configuration, used for the discovery + * request, any identity provider discovery document, and the later sign-in requests. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig) { return fromQuestDB(questdbUrl, null, null, tlsConfig, false); } /** - * Same as {@link #fromQuestDB(String, ClientTlsConfiguration)} but lets the caller permit insecure - * {@code http} transport for the QuestDB server and the discovered identity provider endpoints - * (see {@link Builder#allowInsecureTransport(boolean)}). Intended for local development only. + * Like {@link #fromQuestDB(String, ClientTlsConfiguration)} but permits insecure {@code http} for + * the server and the discovered identity provider endpoints (see + * {@link Builder#allowInsecureTransport(boolean)}). Local development only. */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport) { return fromQuestDB(questdbUrl, null, null, tlsConfig, allowInsecureTransport); } /** - * Same as {@link #fromQuestDB(String, String)} but lets the caller supply the identity provider - * discovery document URL directly (an alternative to {@code issuer}, which otherwise derives it as - * {@code {issuer}/.well-known/openid-configuration}) and an explicit TLS configuration. Either an + * Like {@link #fromQuestDB(String, String)} but accepts the discovery document URL directly (an + * alternative to {@code issuer}, which otherwise derives it as + * {@code {issuer}/.well-known/openid-configuration}) plus an explicit TLS configuration. Either an * {@code issuer} or a {@code discoveryUrl} pins the identity provider; pass both {@code null} to * trust the endpoints the server advertises. * @@ -292,13 +282,12 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin String resolvedIssuer = issuer != null && !issuer.isEmpty() ? issuer : null; String pinnedDiscoveryUrl = discoveryUrl != null && !discoveryUrl.isEmpty() ? discoveryUrl : null; - // When the QuestDB /settings channel is a plaintext, MITM-able http connection (only reachable - // with allowInsecureTransport; the default rejects it), the endpoints it advertises could 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 that advertises 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-supplied endpoints over such a channel. + // 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 && pinnedDiscoveryUrl == null && settingsChannelIsPlaintext(server)) { throw new OidcAuthException() @@ -309,11 +298,11 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin .put("connect to QuestDB over https [url=").put(questdbUrl).put(']'); } - // Fall back to identity provider discovery when the server does not advertise the device - // authorization endpoint (and/or the token endpoint). This contacts the identity provider, whose - // origin must be pinned out of band: the discovery target is never derived from a value the - // server supplied, otherwise a tampered or intercepted /settings could steer discovery - and so - // the credential POSTs - to an attacker, with the co-location and issuer checks passing trivially. + // 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 && pinnedDiscoveryUrl == null) { throw new OidcAuthException() @@ -332,15 +321,14 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin if (tokenEndpoint == null && doc.tokenEndpoint.length() > 0) { tokenEndpoint = doc.tokenEndpoint.toString(); } - // The discovery origin is pinned out of band - the caller's issuer, else the discoveryUrl origin - // (derived after this block) - and that pin, never an issuer the document declares about itself, - // is the trust anchor the endpoint pin binds to. When discovery ran off a pinned discoveryUrl (no - // caller issuer), reject a document whose own "issuer" sits on a different origin (RFC 8414 - // section 3.3): otherwise a tampered or content-injected document at the pinned url could name an - // attacker issuer, co-locate both endpoints under it, and route the device code and long-lived - // refresh token there while the co-location and issuer checks below passed trivially. An identity - // provider that serves its discovery document on a different origin than its endpoints must - // instead be configured with explicit endpoints via OidcDeviceAuth.builder(). + // The endpoint pin's trust anchor is the out-of-band discovery origin (the caller's issuer, else + // the discoveryUrl origin derived after this block), never an issuer the document declares about + // itself. When discovery ran off a pinned discoveryUrl (no caller issuer), reject a document + // whose own "issuer" is on a different origin (RFC 8414 section 3.3): else a tampered or + // content-injected document at the pinned url could name an attacker issuer, co-locate both + // endpoints under it, and route the device code and refresh token there while the checks below + // pass trivially. A provider serving its discovery document on a different origin than its + // endpoints must use explicit endpoints via OidcDeviceAuth.builder(). if (resolvedIssuer == null && pinnedDiscoveryUrl != null && doc.issuer.length() > 0) { Endpoint docIssuer = Endpoint.parse(doc.issuer.toString()); Endpoint discoveryEndpoint = Endpoint.parse(pinnedDiscoveryUrl); @@ -353,12 +341,11 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin } } - // A caller-supplied discoveryUrl pins the identity provider just as an issuer does: derive the pin - // origin from the discoveryUrl itself so validateEndpointOrigins rejects any endpoint - read from the - // discovery document above, or advertised by /settings when it supplied both endpoints and the - // discovery branch was skipped - that does not belong to it. Without this, a tampered response - // advertising both endpoints at one attacker origin would slip past a discoveryUrl pin, the - // co-location check alone passing trivially. + // A caller-supplied discoveryUrl pins the provider just as an issuer does: derive the pin origin + // from it so validateEndpointOrigins rejects any endpoint not on it - whether read from the + // discovery document above or advertised by /settings (when it supplied both endpoints and the + // discovery branch was skipped). Without this, a tampered response advertising both endpoints at one + // attacker origin would slip past a discoveryUrl pin, the co-location check alone passing trivially. if (resolvedIssuer == null && pinnedDiscoveryUrl != null) { resolvedIssuer = originOf(Endpoint.parse(pinnedDiscoveryUrl)); } @@ -404,21 +391,20 @@ public void clearCache() { /** * Frees the network connections and native buffers this instance holds. If a {@link #getToken()} - * sign-in is in flight on another thread, {@code close()} signals it to stop, so the sign-in fails - * with an {@link OidcAuthException} instead of polling on until the device code expires. The signal - * is observed between polls (within about 100ms while a poll interval is being waited out); a poll - * request already in flight is not interrupted, so {@code close()} acquires the instance lock - and - * returns - only once that request finishes or times out, i.e. after at most one HTTP request timeout - * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. Safe to call more - * than once. After close, {@link #getToken()} and {@link #clearCache()} throw. + * 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, i.e. after at most one HTTP request timeout + * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. Idempotent. After + * close, {@link #getToken()} and {@link #clearCache()} throw. */ @Override public void close() { - // flag cancellation before taking the lock: getToken() holds the lock for the whole interactive - // flow, so close() signals the in-flight sign-in to stop with a lock-free volatile write, then - // acquires the lock - which the now-cancelled flow releases once it observes the flag (between - // polls, or after an in-flight poll request returns) - and frees the native resources. close() - // never frees while a flow holds the lock, so there is no use-after-free + // flag cancellation before taking the lock: getToken() 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 { @@ -439,10 +425,9 @@ public String getAuthorizationHeaderValue() { } /** - * Returns a valid token to present to QuestDB. Returns the cached token while it is still - * valid; otherwise refreshes it silently when possible, or runs the interactive device flow. - * The returned token is the id token when the server expects groups encoded in the token, - * and the access token otherwise. + * 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, or the identity provider @@ -452,10 +437,10 @@ public String getToken() { lock.lock(); try { throwIfClosed(); - // only a cached copy of the token getToken() actually serves counts as a cache hit; a grant - // that returned the other kind (an access token when the server wants the id token, or vice - // versa) leaves the served token null, so the flow must re-run rather than report the unusable - // grant as valid and have selectToken() throw on this and every later call + // only the kind of token getToken() 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 re-run the flow 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) { if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { @@ -473,17 +458,16 @@ public String getToken() { } /** - * Returns a valid token like {@link #getToken()} but never starts the interactive device flow and - * never blocks: it returns the cached token while it is valid and silently refreshes it when a - * refresh token is available, otherwise it throws. Designed for the request/flush path of a - * long-lived client, for example {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, - * where an interactive prompt would be inappropriate and a stalled flush unacceptable. Call - * {@link #getToken()} once to sign in before handing this method to a client. + * Like {@link #getToken()} but never starts the interactive device flow and never blocks: 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::getTokenSilently)}, where an interactive prompt + * is inappropriate and a stalled flush unacceptable. Call {@link #getToken()} once to sign in first. *

    - * To keep the flush path responsive it returns promptly or throws promptly - it never waits for an - * interactive {@link #getToken()} in progress on another thread (which would otherwise stall the - * flush for the whole device-code lifetime). While such a sign-in runs there is no token to return - * anyway, so this method throws and the caller should retry once the sign-in completes. + * To keep the flush path responsive it returns or throws promptly - it never waits for an interactive + * {@link #getToken()} on another thread (which would stall the flush for the whole device-code + * lifetime). While such a sign-in runs there is no token to return anyway, so it throws and the caller + * should retry once the sign-in completes. * * @return a non-null, non-empty token * @throws OidcAuthException if no token has been obtained yet, if the cached token expired and could @@ -492,10 +476,10 @@ public String getToken() { */ public String getTokenSilently() { throwIfClosed(); - // never wait on the flush path: getToken()'s interactive sign-in holds the lock for the whole - // device-code lifetime (up to an hour), so acquire it without blocking and fail fast if it is - // held. A sign-in in progress means there is no token to serve yet, so the caller gets a prompt - // exception to retry rather than a stalled flush + // never wait on the flush path: getToken()'s sign-in holds the lock for the whole device-code + // lifetime (up to an hour), so tryLock and fail fast if held. A sign-in in progress means there + // is no token to serve yet, so the caller gets a prompt exception to retry rather than a stalled + // flush if (!lock.tryLock()) { throw new OidcAuthException("a sign-in or token refresh is already in progress on another thread; no token is available without blocking - retry shortly"); } @@ -537,8 +521,8 @@ private static ClientTlsConfiguration defaultTlsConfig() { } private static void discardBody(Response body, int timeoutMillis) { - // best-effort drain after a parse failure so the keep-alive connection stays usable; bounded the - // same way as parseBody so a hostile server cannot wedge the thread here either + // 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 final long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; long totalBytes = 0; try { @@ -562,9 +546,9 @@ private static void discardBody(Response body, int timeoutMillis) { } private static void discoverFromIdp(String issuer, String discoveryUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport, WellKnownDiscoveryParser parser) { - // the discovery document URL is pinned out of band (a caller-supplied discoveryUrl, else built - // from the issuer) - the caller guarantees one of the two is non-null - so the server cannot - // choose where discovery, and the credential POSTs it resolves, are aimed + // the discovery URL is pinned out of band (a caller-supplied discoveryUrl, else built from the + // issuer; the caller guarantees one is non-null), so the server cannot choose where discovery - + // and the credential POSTs it resolves - are aimed String url = discoveryUrl != null ? discoveryUrl : wellKnownUrl(issuer); Endpoint endpoint = Endpoint.parse(url); if (!allowInsecureTransport) { @@ -595,8 +579,8 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura HttpClient.ResponseHeaders response = request.send(DEFAULT_HTTP_TIMEOUT_MILLIS); response.await(DEFAULT_HTTP_TIMEOUT_MILLIS); Response body = response.getResponse(); - // bounded read: parseBody enforces a wall-clock deadline and a byte cap so an untrusted - // server cannot wedge discovery, and its parseLast rejects a truncated document + // parseBody enforces a wall-clock 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 e) { throw new OidcAuthException(e).put(reachError); @@ -609,8 +593,8 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura } private static boolean isDottedIpv4(String host) { - // validate a dotted IPv4 literal (four 0-255 octets) without a DNS lookup, so a hostname that - // merely starts with "127." is not mistaken for the loopback block + // 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; @@ -636,8 +620,8 @@ private static boolean isDottedIpv4(String host) { } private static boolean isLoopbackHost(String host) { - // traffic to a loopback target never leaves the host, so a plaintext /settings fetch to it carries - // no network interception risk; match localhost and the whole IPv4 127.0.0.0/8 block + // loopback traffic never leaves the host, so a plaintext /settings fetch to it has no network + // interception risk; match localhost and the whole IPv4 127.0.0.0/8 block return host != null && (host.equalsIgnoreCase("localhost") || (host.startsWith("127.") && isDottedIpv4(host))); } @@ -646,8 +630,8 @@ private static String originOf(Endpoint endpoint) { } private static void parseBody(Response body, JsonLexer lexer, JsonParser parser, int timeoutMillis) throws JsonException { - // read and parse the whole body, bounded by an overall wall-clock deadline and a cumulative byte - // cap, so a hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming + // read and parse the whole body, bounded by a wall-clock deadline and a cumulative byte cap, so a + // hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming final long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; long totalBytes = 0; while (true) { @@ -677,9 +661,9 @@ private static int parseIntOrZero(CharSequence value) { } private static void putNonNull(StringSink sink, CharSequence tag) { - // clear before storing so a repeated key in the response replaces, rather than 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 + // 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); @@ -695,8 +679,8 @@ private static void requireSecureTransport(boolean isTls, String label, String u } private static boolean sameOrigin(Endpoint a, Endpoint b) { - // scheme (captured by isTls), host and port - the security origin; the path is deliberately not - // compared, the token and device endpoints legitimately differ in path on one authorization server + // 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 return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host); } @@ -715,14 +699,13 @@ private static String sanitizeForDisplay(String value) { i += Character.charCount(cp); } if (firstUnsafe < 0) { - // common case: nothing to strip - return value; - } - // an attacker-influenced device-auth field smuggled in characters that can rewrite or spoof the - // terminal - ANSI escapes, CR/LF, or bidi/zero-width formatting (including supplementary-plane - // "tag" characters that arrive as surrogate pairs) that reorders or hides text - so strip them - // per code point; otherwise a right-to-left override could make the verification URL a human reads - // differ from the one their browser opens + 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; ) { @@ -737,9 +720,9 @@ private static String sanitizeForDisplay(String value) { } private static boolean settingsChannelIsPlaintext(Endpoint server) { - // /settings reached over plaintext http to a non-loopback host is MITM-able (only possible when - // allowInsecureTransport is set; the default rejects it), so the endpoints it advertises must not - // be trusted to route credentials without an out-of-band pin + // /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); } @@ -748,12 +731,12 @@ private static String urlEncode(String value) { } private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint deviceAuthorizationEndpoint, Endpoint issuer) { - // the device code and the 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 configuration - // that splits them across origins (a tampered /settings or discovery document trying to siphon one - // off), and - when the issuer is pinned - reject either endpoint that does not belong to it. The - // pin compares origins, so an identity provider that hosts its endpoints on a different origin than - // its issuer must be configured without an issuer (or with explicit endpoints). + // 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), and - when + // the issuer is pinned - reject either endpoint not on it. The pin compares origins, so a provider + // hosting its endpoints on a different origin than its issuer must be configured without an issuer + // (or with explicit endpoints). if (!sameOrigin(tokenEndpoint, deviceAuthorizationEndpoint)) { throw new OidcAuthException() .put("the OIDC token and device authorization endpoints are on different origins (") @@ -777,13 +760,13 @@ private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint dev } private static void validateTokenChars(CharSequence token, String tokenName) { - // The selected token is written verbatim into the "Authorization: Bearer " header sent to the - // trusted QuestDB server, and used as the PG-wire _sso password. A CR/LF or other control character - // would break out of the header and inject into the request line - the JSON lexer now decodes a \r or - // \n escape in the identity provider's response into a real control byte - and a non-ASCII character - // is silently truncated to one byte by the ASCII header writer. A real OAuth token is printable ASCII, - // so reject anything outside that range rather than route a tampered or corrupt credential onto the - // wire. The token bytes are never embedded in the message: they are the secret this class protects. + // 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. for (int i = 0, n = token.length(); i < n; i++) { char c = token.charAt(i); if (c < 0x20 || c > 0x7e) { @@ -820,7 +803,7 @@ private HttpClient httpClient(boolean isTls) { } private boolean isHttpStatusSuccess() { - // responseStatus holds the numeric HTTP status captured by readResponse; a 2xx starts with '2' + // responseStatus is the numeric HTTP status captured by readResponse; a 2xx starts with '2' return responseStatus.length() > 0 && responseStatus.charAt(0) == '2'; } @@ -831,7 +814,7 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS while (true) { throwIfClosed(); // check the deadline before polling so an expiry that elapsed during the previous sleep aborts - // here, rather than after one more wasted poll round-trip + // 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"); } @@ -841,7 +824,7 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS return; } if (result == POLL_TRANSIENT_ERROR) { - // a non-2xx with no parseable answer; charge it to the transport-error budget so a + // a non-2xx with no parseable answer; charge the transport-error budget so a // persistently failing token endpoint aborts instead of polling until the code expires if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { throw new OidcAuthException().put("the token endpoint returned repeated unexpected responses [httpStatus=").put(responseStatus).put(']'); @@ -849,22 +832,22 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS } else { consecutiveTransportErrors = 0; if (result == POLL_SLOW_DOWN) { - // grow the interval per RFC 8628, but keep it within the same cap as the initial - // value so repeated slow_down responses cannot inflate the wait without bound + // grow the interval per RFC 8628, capped at the same bound as the initial value so + // repeated slow_down responses cannot inflate the wait without bound intervalMillis = Math.min(intervalMillis + SLOW_DOWN_INCREMENT_SECONDS * 1000L, MAX_POLL_INTERVAL_SECONDS * 1000L); } } } catch (HttpClientException e) { - // a brief network blip is fine to retry, but a persistent failure (a rejected TLS - // certificate, a refused connection, an unresolvable host) must surface with its cause - // rather than masquerade as a device-code timeout + // a brief network blip is fine to retry, but a persistent failure (rejected TLS cert, + // refused connection, unresolvable host) must surface with its cause rather than + // masquerade as a device-code timeout if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { throw new OidcAuthException(e).put("the token endpoint became unreachable while waiting for authorization"); } } catch (OidcAuthException e) { - // a garbled / non-JSON body (a JsonException cause) is a transport-class blip and is - // retried on the same budget; a well-formed OAuth error or unexpected response (no - // parse cause) is a real answer from the identity provider and aborts immediately + // a garbled / non-JSON body (a JsonException cause) is a transport-class blip, retried on + // the same budget; a well-formed OAuth error or unexpected response (no parse cause) is a + // real answer from the identity provider and aborts immediately if (!(e.getCause() instanceof JsonException)) { throw e; } @@ -872,8 +855,8 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS throw e; } } - // wait for the next poll, but 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 + // 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)); } } @@ -885,8 +868,8 @@ private int pollOnce(String deviceCode) { appendParam(formSink, "client_id", clientId); tokenParser.clear(); - // a transport failure here propagates to pollForToken, which retries a brief blip but aborts - // on a persistent failure rather than swallowing it as a pending authorization + // a transport failure here propagates to pollForToken, which retries a brief blip but aborts on a + // persistent failure 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, so handle the @@ -900,8 +883,8 @@ private int pollOnce(String deviceCode) { } throw OidcAuthException.oauthError(tokenParser.error, tokenParser.errorDescription); } - // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx status is a - // malformed or hostile answer - charge it to the transport-error budget rather than trusting it + // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx status is + // malformed or hostile - charge the transport-error budget rather than trust it if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { if (isHttpStatusSuccess()) { storeTokens(tokenParser); @@ -910,7 +893,7 @@ private int pollOnce(String deviceCode) { return POLL_TRANSIENT_ERROR; } // no tokens and no OAuth error: a 2xx is a definitive but malformed answer and aborts; a non-2xx - // (a gateway 5xx, an empty body) is a transport-class blip - retry rather than abort the sign-in + // (gateway 5xx, empty body) is a transport-class blip - retry rather than abort the sign-in if (isHttpStatusSuccess()) { throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); } @@ -933,17 +916,17 @@ private void postForm(Endpoint endpoint, JsonParser parser) { } private void readResponse(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 + // 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 of its bytes - 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 so the keep-alive connection stays usable. + // 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. CharSequence raw = statusCode.asAsciiCharSequence(); for (int i = 0, n = raw.length(); i < n; i++) { char c = raw.charAt(i); @@ -958,8 +941,8 @@ private void readResponse(HttpClient.ResponseHeaders response, JsonParser parser try { parseBody(body, jsonLexer, parser, httpTimeoutMillis); } catch (JsonException e) { - // drain the rest so the keep-alive connection stays usable; never embed the body, it may - // carry tokens + // drain the rest to keep the keep-alive connection usable; never embed the body, it may carry + // tokens discardBody(body, httpTimeoutMillis); throw new OidcAuthException(e) .put("could not parse the identity provider response [httpStatus=").put(responseStatus).put(']'); @@ -984,9 +967,9 @@ private void runDeviceFlow() { 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 that carries no OAuth - // error (handled above) is a malformed or hostile answer; reject it rather than prompt the user and - // poll on it - the same 2xx gate pollOnce and tryRefresh apply before trusting a token + // 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(']'); } @@ -1028,7 +1011,7 @@ private String selectToken() { 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) poll interval; Os.sleep ignores thread + // instead of after a full (possibly slow_down-inflated) interval; Os.sleep ignores thread // interrupts, so polling the closed flag is the only way to stay responsive to cancellation long remaining = millis; while (remaining > 0) { @@ -1040,20 +1023,20 @@ private void sleepBetweenPolls(long millis) { } private void storeTokens(TokenResponseParser parser) { - // reject a token carrying control or non-ASCII characters before caching it: 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 + // 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 validateTokenChars(parser.accessToken, "access_token"); validateTokenChars(parser.idToken, "id_token"); accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null; idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null; - // a refresh response usually omits a new refresh token, in that case we keep the current one + // a refresh response usually omits a new refresh token; keep the current one in that case if (parser.refreshToken.length() > 0) { refreshToken = parser.refreshToken.toString(); } - // clamp like the device-side expires_in: fall back to the default for a non-positive value and 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) + // 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); expiresAtMillis = System.currentTimeMillis() + ttlSeconds * 1000L; } @@ -1077,20 +1060,19 @@ private boolean tryRefresh() { try { postForm(tokenEndpoint, tokenParser); } catch (HttpClientException e) { - // could not reach the token endpoint, fall back to the interactive flow + // could not reach the token endpoint; fall back to the interactive flow return false; } catch (OidcAuthException e) { - // postForm only throws an OidcAuthException on a parse failure (a garbled / unparseable refresh - // response), never an OAuth error: a genuine OAuth error arrives in tokenParser.error and is - // handled by the hasRequiredToken check below. So treat this as a transient blip and fall back to - // the interactive flow rather than fail the whole getToken() call + // 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; } - // only treat the refresh as a success if a clean 2xx response (no OAuth error) returned the token - // getToken() actually serves (the id token when groups are encoded in it, the access token - // otherwise). A refresh that omits the id token - which RFC 6749 permits and many providers do - - // or one that carries an error or arrives under a non-2xx status must fall back to the interactive - // flow rather than be cached (and later fail in selectToken()) + // 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 id + // token - which RFC 6749 permits and many providers do - or carries an error or a non-2xx status + // must fall back to the interactive flow rather than be cached (and later fail in selectToken()) boolean hasRequiredToken = (groupsInToken ? tokenParser.idToken.length() > 0 : tokenParser.accessToken.length() > 0) @@ -1100,8 +1082,8 @@ && isHttpStatusSuccess() storeTokens(tokenParser); return true; } - // the refresh token expired or was revoked, or it did not return the token we need; - // fall back to the interactive flow + // the refresh token expired or was revoked, or did not return the token we need; fall back to the + // interactive flow return false; } @@ -1128,8 +1110,8 @@ private Builder() { /** * Permits insecure {@code http} (rather than {@code https}) for the device authorization and - * token endpoints. Tokens then travel in cleartext, so this is rejected by default and should - * only be enabled for local development on a trusted network. Defaults to {@code false}. + * token endpoints. Tokens then travel in cleartext, so this is rejected by default; enable only + * for local development on a trusted network. Defaults to {@code false}. */ public Builder allowInsecureTransport(boolean allowInsecureTransport) { this.allowInsecureTransport = allowInsecureTransport; @@ -1165,8 +1147,8 @@ public OidcDeviceAuth build() { requireSecureTransport(deviceEndpoint.isTls, "device authorization endpoint", deviceAuthorizationEndpoint); requireSecureTransport(parsedTokenEndpoint.isTls, "token endpoint", tokenEndpoint); } - // 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 + // 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(); return new OidcDeviceAuth(this, tls); @@ -1178,8 +1160,8 @@ public Builder clientId(String clientId) { } /** - * Sets how many seconds before the real expiry a cached token is treated as expired. Defaults - * to 30 seconds. The margin absorbs clock drift and request latency. + * Seconds before the real expiry at which a cached token is treated as expired, absorbing clock + * drift and request latency. Defaults to 30. */ public Builder clockSkewSeconds(int clockSkewSeconds) { this.clockSkewSeconds = clockSkewSeconds; @@ -1209,11 +1191,10 @@ public Builder httpTimeoutMillis(int httpTimeoutMillis) { /** * Pins the identity provider by its {@code issuer} origin (for example * {@code https://idp.example.com}). When set, {@link #build()} rejects a token or device - * authorization endpoint that does not belong to this origin, so a compromised or tampered - * configuration cannot redirect the device code and refresh token to an attacker. - * {@link #fromQuestDB(String, String)} sets it for you when discovering from a server. The - * endpoints of an identity provider that hosts them on a different origin than its issuer are - * rejected when pinned; configure such a provider without an issuer. Optional. + * authorization endpoint not on this origin, so a compromised or tampered configuration cannot + * redirect the device code and refresh token to an attacker. {@link #fromQuestDB(String, String)} + * sets it for you when discovering from a server. A provider hosting its endpoints on a different + * origin than its issuer is rejected when pinned; configure it without an issuer. Optional. */ public Builder issuer(String issuer) { this.issuer = issuer; @@ -1368,15 +1349,14 @@ 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 would corrupt - // the outbound Host header; in the path or query it would inject 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 character (isUnsafeForDisplay, scanned per code point so a - // supplementary-plane one is not missed) would reorder, hide or forge the text when the url is - // echoed into a log line or the parse error messages below. Rejecting up front keeps the raw url - // safe both on the wire and on screen. + // 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)) { @@ -1402,8 +1382,8 @@ static Endpoint parse(String url) { String hostPort = pathStart < 0 ? url.substring(hostStart) : url.substring(hostStart, pathStart); String path = pathStart < 0 ? "/" : url.substring(pathStart); 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 + // 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(':'); @@ -1467,8 +1447,8 @@ public void onEvent(int code, CharSequence tag, int position) { break; case JsonLexer.EVT_NAME: if (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 + // 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 (depth == 2 && isInConfig) { @@ -1635,8 +1615,8 @@ public void onEvent(int code, CharSequence tag, int position) { depth--; break; case JsonLexer.EVT_NAME: - // the standard OIDC discovery document is a flat top-level object; only read its - // top-level keys so a nested value cannot be mistaken for an endpoint + // 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 (depth == 1) { if (Chars.equals("device_authorization_endpoint", tag)) { field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT; 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 f82c12626..9e5543c58 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 @@ -91,11 +91,11 @@ public long lo() { } public Fragment recv(int timeout) { - // When a positive timeout is given, bound the whole call to it, not each socket read. This loop keeps - // re-reading while a chunk-size line (or the chunk-data-end CRLF) is still incomplete, so without one - // shared deadline a server that dribbles those bytes - one per timeout window - would keep a single - // recv() running for (line length) x timeout and defeat a caller's wall-clock bound (e.g. - // OidcDeviceAuth.parseBody). A non-positive timeout keeps the legacy "no bound" behaviour. + // 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 wall-clock 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) { 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 c3a337e1d..02c305051 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 @@ -36,10 +36,9 @@ public interface Response { Fragment recv(); /** - * Receives the next fragment of response data. When {@code timeout} is positive it bounds the whole - * call to at most {@code timeout} milliseconds in total (not per socket read), so a server that - * dribbles the body one byte at a time cannot keep a single call running past it; a non-positive - * {@code timeout} disables the bound. + * 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. * * @param timeout the receive timeout in milliseconds * @return the received fragment, or null once the body has been fully read 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 3f28b5b0d..cd2e75c03 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 @@ -298,8 +298,8 @@ 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); - // direct lookup in the shared hex table (returns -1 for a non-hex char), cheaper than - // Character.digit; the table is ASCII-sized, so a code point above 127 is never a hex digit + // 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; @@ -351,16 +351,14 @@ private CharSequence getCharSequence(long lo, long hi, int position, boolean has } else { utf8DecodeCacheAndBuffer(lo, hi - 1, position); } - // the decode above assembled the raw bytes between the quotes verbatim; resolve JSON string escape - // sequences only when the scan actually saw a backslash. The common no-escape value (and every - // escape-free name) skips unescape() entirely and returns the assembled sink directly. + // 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 (hasEscape), so at least one escape is present; walk the - // value once, copying plain characters and resolving each escape in place. No separate leading scan - // to re-find the first backslash - the lexer already proved one exists. + // 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; 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 f3092d479..82519f40d 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 @@ -408,11 +408,10 @@ public static AbstractLineHttpSender createLineSender( throw new LineSenderException("Unsupported protocol version: " + protocolVersion); } if (httpTokenProvider != null) { - // wire the per-request token provider. The constructor built the initial request before the - // provider was set, so it carries no token yet; defer pulling the first token off the build - // path to the first row (table()), instead of calling getToken() here. That lets a provider - // that signs in lazily - e.g. OidcDeviceAuth::getTokenSilently - be wired before the sign-in - // has completed, and keeps the token pull on the use/flush path the provider documents + // The constructor already built the initial request without a token. Defer the first + // getToken() off this build path to the first row (table()), so a provider that signs in + // lazily - e.g. OidcDeviceAuth::getTokenSilently - can be wired before sign-in completes, + // and the token pull stays on the use/flush path the provider documents. sender.httpTokenProvider = httpTokenProvider; sender.isTokenPending = true; } @@ -502,8 +501,8 @@ public Sender longColumn(CharSequence name, long value) { @TestOnly public void putRawMessage(Utf8Sequence msg) { - // pull the deferred provider token (if any) so a raw message sent as the first row of a request - // carries it, just like table() does; a no-op when no provider is configured + // 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; @@ -561,8 +560,8 @@ public Sender table(CharSequence table) { if (table.length() == 0) { throw new LineSenderException("table name cannot be empty"); } - // pull the deferred provider token (if any) before writing 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 + // 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(); @@ -765,7 +764,7 @@ private HttpClient.Request newRequest(boolean pullProviderToken) { } else if (httpTokenProvider != null) { if (pullProviderToken) { // pull a fresh token per request so a long-lived sender follows token refreshes; reject a - // null/empty/blank return (the HttpTokenProvider contract forbids it) with a clear error + // null/empty/blank return (forbidden by the HttpTokenProvider contract) with a clear error // rather than emit a malformed "Authorization: Bearer " header the server only 401s on CharSequence token = httpTokenProvider.getToken(); if (Chars.isBlank(token)) { @@ -773,12 +772,11 @@ private HttpClient.Request newRequest(boolean pullProviderToken) { } r.authToken(token); } else { - // do NOT pull the provider token on the construct/flush path: getToken() can throw (a - // provider that has not signed in yet, or a failed silent refresh), and pulling it here - - // after client.newRequest() has already reset and re-headered the shared request but - // before withContent() - would leave a half-built request behind and corrupt the sender, - // turning an already-successful flush into a thrown exception. Defer to the first row - // (stampTokenIfPending), where a failed pull is retriable and rebuilds the request cleanly + // do NOT pull the token on the construct/flush path: getToken() can throw (not signed in + // yet, or a failed silent refresh). Here - after client.newRequest() reset and re-headered + // the shared request but before withContent() - a throw would leave a half-built request + // and corrupt the sender, turning an already-successful flush into an exception. Defer to + // the first row (stampTokenIfPending), where a failed pull is retriable and rebuilds cleanly. isTokenPending = true; } } else if (authToken != null) { @@ -816,13 +814,13 @@ private boolean rowAdded() { private void stampTokenIfPending() { if (isTokenPending) { - // the construct/flush path deferred the provider token so a provider that signs in lazily (e.g. + // The construct/flush path deferred the token so a lazily-signing-in provider (e.g. // OidcDeviceAuth::getTokenSilently) 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 of - // this request, so pull the token and rebuild the still-empty request to carry it before any - // row data goes in. Clear the flag only after newRequest(true) succeeds, so a pull that throws - // (not signed in yet, or a failed refresh) leaves the stamp pending: the next row re-runs this - // and client.newRequest() fully rebuilds the request, so the sender is never left corrupted + // failure never strikes after a successful send. The caller is now starting the first row, so + // rebuild the still-empty request to carry the token before any row data goes in. Clear the + // flag only after newRequest(true) succeeds: a pull that throws (not signed in yet, or a failed + // refresh) leaves the stamp pending, so the next row re-runs this and fully rebuilds the + // request - the sender is never left corrupted. request = newRequest(true); isTokenPending = false; } 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 3e07e250f..4346ce9e9 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 @@ -52,14 +52,13 @@ default void putAsPrintable(CharSequence nonPrintable) { } default void putAsPrintable(char c) { - // escape control characters (C0/C1 and DEL) and Unicode "format" characters - the bidi - // embeddings/overrides/isolates, the LRM/RLM marks, zero-width joiners and the BOM - to a visible - // \\uXXXX. Left raw, attacker-influenced text (an ILP server's JSON error body, a column name) could - // reorder, hide or forge what a human reads in a terminal or a log line; escaping rather than - // stripping keeps the original visible for diagnosis. Scanning per UTF-16 unit covers every BMP - // threat; a legitimate supplementary-plane char (an emoji surrogate pair) is neither a control nor a - // format character and passes through unchanged. The full four hex digits are emitted, so a format - // char above U+00FF (e.g. U+202E) renders correctly rather than truncated to its low byte. + // escape control chars (C0/C1, DEL) and Unicode format chars - bidi embeddings/overrides/isolates, + // LRM/RLM marks, zero-width joiners, the BOM - to a visible \\uXXXX. Left raw, attacker-influenced + // text (an ILP server's JSON error body, a column name) could reorder, hide or forge what a human + // reads in a terminal or log; escaping rather than stripping keeps it visible for diagnosis. Per + // UTF-16-unit scanning covers every BMP threat; a supplementary-plane char (emoji surrogate pair) is + // neither control nor format and passes through. Emitting all four hex digits keeps a format char + // above U+00FF (e.g. U+202E) correct rather than truncated to its low byte. if (!Character.isISOControl(c) && Character.getType(c) != Character.FORMAT) { put(c); } else { From 7b9d20f54b7ee25ba42a3b2c245744d4d3f46085 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 12:28:06 +0100 Subject: [PATCH 029/192] Add OIDC browser-open prompt and DiscoveryOptions DeviceCodePrompt.openBrowser() renders the device-code challenge and then opens the verification URL in the local default browser, best-effort: a new package-private BrowserLauncher allowlists http(s) schemes (rejecting javascript:/data:/file: from a hostile or MITM'd identity-provider response) and skips silently on a headless JVM or a runtime without the java.desktop module, so sign-in never breaks and the URL and code are always printed. Collapse OidcDeviceAuth.fromQuestDB's seven overloads into two: fromQuestDB(url) and fromQuestDB(url, DiscoveryOptions). DiscoveryOptions carries the issuer, discovery URL, TLS config, the insecure-transport opt-in, and the device-code prompt. Threading the prompt through the discovery path is the point: a custom prompt (such as openBrowser) previously worked only via the explicit builder(), which forgoes /settings discovery. Migrate the OidcDeviceAuth test call sites to the options form and add BrowserLauncherTest, which reaches the package-private allowlist by reflection (the client is an open module). Update the example and the README, including two now-removed overload references. Tests: OidcDeviceAuthTest (90) and BrowserLauncherTest (3) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 +- .../client/cutlass/auth/BrowserLauncher.java | 85 ++++++++++ .../client/cutlass/auth/DeviceCodePrompt.java | 34 ++++ .../client/cutlass/auth/OidcDeviceAuth.java | 152 ++++++++++-------- .../cutlass/auth/BrowserLauncherTest.java | 78 +++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 47 +++--- .../example/sender/OidcDeviceFlowExample.java | 3 + 7 files changed, 328 insertions(+), 88 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java diff --git a/README.md b/README.md index 3252102f9..880eb8979 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,18 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.c Prefer `httpTokenProvider(auth::getTokenSilently)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. +On a local terminal you can also open the verification URL in the default browser automatically with `DeviceCodePrompt.openBrowser()`, in addition to printing it: + +```java +try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "https://questdb.example.com:9000", + new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.openBrowser()))) { + auth.getToken(); +} +``` + +The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. Pass any `DeviceCodePrompt` (via `DiscoveryOptions.prompt(...)`, or `builder().prompt(...)` for explicit configuration) to render the challenge yourself, for example a clickable link or QR code in a notebook. + The same token can be presented to QuestDB over any auth path the server already validates: - **REST API:** send it as an `Authorization: Bearer ` header (`auth.getAuthorizationHeaderValue()` returns the full value). @@ -209,12 +221,13 @@ Discovery via `fromQuestDB(...)` reads the OIDC client id, scope and endpoints f ```java try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( - "https://questdb.example.com:9000", "https://idp.example.com")) { + "https://questdb.example.com:9000", + new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) { auth.getToken(); } ``` -By default the device authorization and token endpoints must use `https`, so tokens are never sent in cleartext; an `http` endpoint is rejected. For local development against an `http` endpoint, opt in explicitly with `.allowInsecureTransport(true)` on the builder, or `OidcDeviceAuth.fromQuestDB(url, true)`. +By default the device authorization and token endpoints must use `https`, so tokens are never sent in cleartext; an `http` endpoint is rejected. For local development against an `http` endpoint, opt in explicitly with `.allowInsecureTransport(true)` on the builder, or `OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true))`. `fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin, and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered `/settings` cannot move it. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` (optionally with `.issuer(...)`) instead of discovering it. 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..41c912340 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java @@ -0,0 +1,85 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 { + + private BrowserLauncher() { + } + + /** + * 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, or on a launch failure. 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) { + 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/DeviceCodePrompt.java b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java index c08eebd18..2f4447d3b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java @@ -56,6 +56,40 @@ public interface DeviceCodePrompt { 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. + * + * @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. 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 index f85bebe75..2bd0087fb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -189,7 +189,7 @@ public static Builder builder() { * 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 with {@link #fromQuestDB(String, String)}. + * 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 @@ -197,74 +197,27 @@ public static Builder builder() { * authorization endpoint and no issuer was pinned to discover it */ public static OidcDeviceAuth fromQuestDB(String questdbUrl) { - return fromQuestDB(questdbUrl, null, null, defaultTlsConfig(), false); + return fromQuestDB(questdbUrl, new DiscoveryOptions()); } /** - * Like {@link #fromQuestDB(String)} but permits insecure {@code http} for the server and the - * discovered identity provider endpoints (see {@link Builder#allowInsecureTransport(boolean)}). - * Local development only. - */ - public static OidcDeviceAuth fromQuestDB(String questdbUrl, boolean allowInsecureTransport) { - return fromQuestDB(questdbUrl, null, null, defaultTlsConfig(), allowInsecureTransport); - } - - /** - * Like {@link #fromQuestDB(String)} but pins the identity provider by its {@code issuer} origin - * (for example {@code https://idp.example.com}). The issuer serves two roles: - *

      - *
    • when the server does not advertise the device authorization endpoint (today's servers, - * and older ones), 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}, so - * a tampered {@code /settings} cannot choose where credentials are sent;
    • - *
    • it pins the token and device authorization endpoints: any endpoint not on the issuer - * origin is rejected, so a compromised-but-TLS-valid server cannot redirect the sign-in.
    • - *
    - */ - public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer) { - return fromQuestDB(questdbUrl, issuer, null, defaultTlsConfig(), false); - } - - /** - * Like {@link #fromQuestDB(String, String)} but permits insecure {@code http} for the server and - * the discovered identity provider endpoints (see {@link Builder#allowInsecureTransport(boolean)}). - * Local development only. - */ - public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, boolean allowInsecureTransport) { - return fromQuestDB(questdbUrl, issuer, null, defaultTlsConfig(), allowInsecureTransport); - } - - /** - * Like {@link #fromQuestDB(String)} but with an explicit TLS configuration, used for the discovery - * request, any identity provider discovery document, and the later sign-in requests. - */ - public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig) { - return fromQuestDB(questdbUrl, null, null, tlsConfig, false); - } - - /** - * Like {@link #fromQuestDB(String, ClientTlsConfiguration)} but permits insecure {@code http} for - * the server and the discovered identity provider endpoints (see - * {@link Builder#allowInsecureTransport(boolean)}). Local development only. - */ - public static OidcDeviceAuth fromQuestDB(String questdbUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport) { - return fromQuestDB(questdbUrl, null, null, tlsConfig, allowInsecureTransport); - } - - /** - * Like {@link #fromQuestDB(String, String)} but accepts the discovery document URL directly (an - * alternative to {@code issuer}, which otherwise derives it as - * {@code {issuer}/.well-known/openid-configuration}) plus an explicit TLS configuration. Either an - * {@code issuer} or a {@code discoveryUrl} pins the identity provider; pass both {@code null} to - * trust the endpoints the server advertises. + * Discovers the OIDC configuration from a running QuestDB server, like {@link #fromQuestDB(String)}, + * but with explicit {@link DiscoveryOptions}: an identity provider pin (issuer or discovery URL), 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 - * @param issuer the identity provider origin to pin, or {@code null} - * @param discoveryUrl the identity provider discovery document URL to pin, or {@code null} - * @param tlsConfig the TLS configuration for the discovery and sign-in requests - * @param allowInsecureTransport permits insecure {@code http} for the server and identity provider + * @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 or discovery URL was pinned */ - public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, String discoveryUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport) { + public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions options) { + String issuer = options.issuer; + String discoveryUrl = options.discoveryUrl; + 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); @@ -370,6 +323,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, String issuer, Strin .issuer(resolvedIssuer) .allowInsecureTransport(allowInsecureTransport) .tlsConfig(tlsConfig) + .prompt(options.prompt) .build(); } @@ -1192,7 +1146,7 @@ public Builder httpTimeoutMillis(int httpTimeoutMillis) { * Pins the identity provider by its {@code issuer} origin (for example * {@code https://idp.example.com}). When set, {@link #build()} rejects a token or device * authorization endpoint not on this origin, so a compromised or tampered configuration cannot - * redirect the device code and refresh token to an attacker. {@link #fromQuestDB(String, String)} + * redirect the device code and refresh token to an attacker. {@link #fromQuestDB(String, DiscoveryOptions)} * sets it for you when discovering from a server. A provider hosting its endpoints on a different * origin than its issuer is rejected when pinned; configure it without an issuer. Optional. */ @@ -1226,6 +1180,74 @@ public Builder tokenEndpoint(String tokenEndpoint) { } } + /** + * Options for {@link #fromQuestDB(String, DiscoveryOptions)}: how to pin the identity provider + * (issuer or discovery URL), 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 discoveryUrl; + private String issuer; + private DeviceCodePrompt prompt = DeviceCodePrompt.SYSTEM_OUT; + private ClientTlsConfiguration tlsConfig; + + /** + * Permits insecure {@code http} for both the QuestDB server and the discovered identity provider + * endpoints. Tokens and the device code then travel in cleartext, so this is rejected by default; + * 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 discovery document URL directly, an alternative to + * {@link #issuer(String)} (which otherwise derives {@code {issuer}/.well-known/openid-configuration}). + * Either pins where discovery - and the credential requests it resolves - are aimed, so a tampered + * {@code /settings} cannot redirect them. Optional. + */ + public DiscoveryOptions discoveryUrl(String discoveryUrl) { + this.discoveryUrl = discoveryUrl; + 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 pins the token and device authorization endpoints, + * so any endpoint not on the issuer origin is rejected. A provider hosting its endpoints on a + * different origin than its issuer must be configured without an issuer. Optional. + */ + public DiscoveryOptions issuer(String issuer) { + this.issuer = issuer; + return this; + } + + /** + * Sets how the device code challenge is shown to the user, for example + * {@link DeviceCodePrompt#openBrowser()} to also open the verification URL in a browser. Defaults + * to {@link DeviceCodePrompt#SYSTEM_OUT}. + */ + public DiscoveryOptions prompt(DeviceCodePrompt prompt) { + this.prompt = prompt != null ? prompt : DeviceCodePrompt.SYSTEM_OUT; + 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; + } + } + private static final class DeviceAuthorizationResponseParser implements JsonParser, Mutable { private static final int FIELD_DEVICE_CODE = 1; private static final int FIELD_ERROR = 7; 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..8604e1cbc --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java @@ -0,0 +1,78 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +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 { + // a rejected or absent URL returns before touching java.awt.Desktop, so open() must not throw + // (and the test never launches a real browser, so it is safe on a desktop machine too) + invokeOpen(null); + invokeOpen("javascript:alert(1)"); + invokeOpen("not a url"); + } + + @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 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/OidcDeviceAuthTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java index ab5ce73dd..c241de94b 100644 --- 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 @@ -586,7 +586,7 @@ public void testNonNumericStatusCodeRejected() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { auth.getToken(); Assert.fail("expected a malformed status code to be rejected"); } catch (OidcAuthException e) { @@ -654,7 +654,7 @@ public void testDiscoveryDefaultsScopeToOpenid() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { Assert.assertEquals("ACCESS-SCOPE", auth.getToken()); Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("groups")); @@ -694,7 +694,7 @@ public void testDiscoveryIgnoresPreferencesKeys() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + 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.getToken()); @@ -720,7 +720,7 @@ public void testDiscoveryRejectsMissingClientId() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + 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")); @@ -744,7 +744,7 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + 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")); @@ -766,7 +766,7 @@ public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Except } // 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, true)) { + 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")); @@ -969,7 +969,7 @@ public void testFromQuestDbDiscoversDeviceEndpointFromIssuer() throws Exception 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(""), server.httpUrl(""), true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().issuer(server.httpUrl("")).allowInsecureTransport(true))) { // settings advertise groups.encoded.in.token=true, so getToken() returns the id token Assert.assertEquals("ID-WK", auth.getToken()); } @@ -998,7 +998,7 @@ public void testFromQuestDbDiscoversFromDiscoveryUrl() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)).allowInsecureTransport(true))) { Assert.assertEquals("ID-DU", auth.getToken()); } } @@ -1024,7 +1024,7 @@ public void testFromQuestDbDiscoveryDocMissingDeviceEndpointRejected() throws Ex }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), server.httpUrl(""), true)) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().issuer(server.httpUrl("")).allowInsecureTransport(true))) { Assert.fail("expected discovery to fail"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device_authorization_endpoint")); @@ -1049,7 +1049,7 @@ public void testFromQuestDbDiscoveryRunsFlow() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { // discovery advertises groups.encoded.in.token=true, so getToken() must return the id token Assert.assertEquals("ID-D", auth.getToken()); } @@ -1081,7 +1081,7 @@ public void testFromQuestDbDiscoveryUrlPinAcceptsOnOriginAdvertisedEndpoints() t }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)).allowInsecureTransport(true))) { Assert.assertEquals("ID-DUP", auth.getToken()); } Assert.assertFalse("discovery must be skipped when /settings advertises both endpoints", wellKnownHit.get()); @@ -1115,7 +1115,7 @@ public void testFromQuestDbDiscoveryUrlPinRejectsForeignIssuerInDocument() throw "https://attacker.example")); }; try (MockOidcServer server = new MockOidcServer(handler)) { - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, server.httpUrl(WELL_KNOWN_PATH), null, true)) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)).allowInsecureTransport(true))) { Assert.fail("expected the discoveryUrl pin to reject a document declaring a foreign issuer"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origin than the pinned discovery url")); @@ -1137,7 +1137,7 @@ public void testFromQuestDbDiscoveryUrlPinRejectsOffOriginAdvertisedEndpoints() }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), null, "https://trusted-idp.example/.well-known/openid-configuration", null, true)) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl("https://trusted-idp.example/.well-known/openid-configuration").allowInsecureTransport(true))) { Assert.fail("expected the discoveryUrl pin to reject the off-origin endpoints"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); @@ -1159,7 +1159,7 @@ public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), "https://idp.attacker.example", true)) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.attacker.example").allowInsecureTransport(true))) { Assert.fail("expected the issuer pin to reject the off-origin endpoints"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); @@ -1183,7 +1183,7 @@ public void testFromQuestDbRejectsCrlfInjectedAdvertisedEndpoint() throws Except }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + 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")); @@ -1216,7 +1216,7 @@ public void testFromQuestDbRejectsMissingDeviceEndpoint() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), true)) { + 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")); @@ -1233,7 +1233,7 @@ public void testFromQuestDbRejectsOidcDisabled() throws Exception { 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(""), true)) { + 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")); @@ -1701,7 +1701,7 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc 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, (String) null, true)) { + 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")); @@ -1709,7 +1709,7 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc // 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, server.httpUrl(""), true)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(questdbUrl, new OidcDeviceAuth.DiscoveryOptions().issuer(server.httpUrl("")).allowInsecureTransport(true))) { Assert.assertNotNull(auth); } } @@ -1938,7 +1938,7 @@ public void testOversizedSettingsBodyAbortsAtSizeCap() throws Exception { // 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(""), true)) { + 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 @@ -2448,7 +2448,7 @@ public void testTruncatedSettingsResponseRejected() throws Exception { 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(""), true)) { + 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")); @@ -2672,6 +2672,11 @@ private static String deviceAuthorizationJson(int interval, int expiresIn) { + "}"; } + // DiscoveryOptions permitting insecure http, the common shape for tests reaching a plaintext mock server + private static OidcDeviceAuth.DiscoveryOptions insecure() { + return new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true); + } + // 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 invokeIsLoopbackHost(String host) throws Exception { diff --git a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java index 5e6adece4..5c698b505 100644 --- a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -16,6 +16,9 @@ public class OidcDeviceFlowExample { public static void main(String[] args) { // Discover client id, scope, endpoints and the groups-in-token mode from the server. // Alternatively, configure the identity provider explicitly with OidcDeviceAuth.builder(). + // On a local terminal, also open the verification URL in a browser by passing options: + // import io.questdb.client.cutlass.auth.DeviceCodePrompt; + // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.openBrowser())) try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { auth.getToken(); // sign in once (prompts on first use, then caches and refreshes silently) From 6ac442b41674084f068637c4fb24f496f0624dde Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 13:29:27 +0100 Subject: [PATCH 030/192] Make OIDC browser-open the default The default device-code prompt is now openBrowser() in both the builder and DiscoveryOptions, so an interactive sign-in prints the verification URL and code and also opens the URL in the local default browser when one is available. SYSTEM_OUT becomes the explicit print-only opt-out. A new questdb.client.oidc.open.browser system property (default true) gates the launch in BrowserLauncher, so a server, automation or CI host can suppress it process-wide. OidcDeviceAuthTest sets it false so no device-flow test launches a real browser, under maven or an IDE - the default prompt would otherwise pop a tab for every flow that reaches the prompt. Update the javadocs, README and example accordingly. Tests: OidcDeviceAuthTest (90) and BrowserLauncherTest (4) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 9 +++--- .../client/cutlass/auth/BrowserLauncher.java | 14 +++++++-- .../client/cutlass/auth/DeviceCodePrompt.java | 12 +++++--- .../client/cutlass/auth/OidcDeviceAuth.java | 19 +++++++----- .../cutlass/auth/BrowserLauncherTest.java | 18 ++++++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 29 ++++++++++++------- .../example/sender/OidcDeviceFlowExample.java | 12 ++++---- 7 files changed, 78 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 880eb8979..b61e5a108 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ try (Sender sender = Sender.fromConfig("https::addr=localhost:9000;tls_verify=un For QuestDB Enterprise instances secured with OIDC, `OidcDeviceAuth` signs a user in interactively using the [OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628). It works from environments that have no local browser — a remote notebook kernel, a container, a headless job — because the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider. -On first use it prints a verification URL and a short code; open the URL, enter the code, and the token is cached in memory and refreshed silently on later calls. +On first use it prints a verification URL and a short code, and opens the URL in your default browser when one is available; authorize there (or open the URL on any device, such as your phone), enter the code, and the token is cached in memory and refreshed silently on later calls. ```java import io.questdb.client.Sender; @@ -188,18 +188,17 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.c Prefer `httpTokenProvider(auth::getTokenSilently)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. -On a local terminal you can also open the verification URL in the default browser automatically with `DeviceCodePrompt.openBrowser()`, in addition to printing it: +By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: ```java +// print only, do not open a browser: try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( "https://questdb.example.com:9000", - new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.openBrowser()))) { + new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) { auth.getToken(); } ``` -The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. Pass any `DeviceCodePrompt` (via `DiscoveryOptions.prompt(...)`, or `builder().prompt(...)` for explicit configuration) to render the challenge yourself, for example a clickable link or QR code in a notebook. - The same token can be presented to QuestDB over any auth path the server already validates: - **REST API:** send it as an `Authorization: Bearer ` header (`auth.getAuthorizationHeaderValue()` returns the full value). 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 index 41c912340..da2d72cc1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java @@ -35,16 +35,24 @@ */ 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() { } /** * 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, or on a launch failure. May - * throw a {@link LinkageError} when the {@code java.desktop} module is absent from the runtime; - * the caller treats that as "no browser available". + * 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 (!Boolean.parseBoolean(System.getProperty(OPEN_BROWSER_PROPERTY, "true"))) { + return; + } URI uri = safeHttpUri(url); if (uri == null) { return; 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 index 2f4447d3b..8389c43d1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java @@ -31,14 +31,17 @@ * 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 {@link #SYSTEM_OUT default implementation} prints instructions to {@code System.out}. Supply - * your own to render the challenge elsewhere, e.g. a clickable link or a QR code in a notebook. + * 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 plain ASCII. + * Prints the sign-in instructions to {@code System.out} as plain ASCII, 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(); @@ -61,7 +64,8 @@ public interface DeviceCodePrompt { * 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. + * headless host the printed URL and code remain the way in. This is the default prompt when none + * is configured. * * @return a prompt that prints the challenge and opens the verification URL in a browser */ 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 index 2bd0087fb..8d99c3aad 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1054,7 +1054,7 @@ public static final class Builder { private boolean groupsInToken; private int httpTimeoutMillis = DEFAULT_HTTP_TIMEOUT_MILLIS; private String issuer; - private DeviceCodePrompt prompt = DeviceCodePrompt.SYSTEM_OUT; + private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser(); private String scope = DEFAULT_SCOPE; private ClientTlsConfiguration tlsConfig; private String tokenEndpoint; @@ -1157,10 +1157,12 @@ public Builder issuer(String issuer) { /** * Sets how the device code challenge is shown to the user. Defaults to - * {@link DeviceCodePrompt#SYSTEM_OUT}. + * {@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.SYSTEM_OUT; + this.prompt = prompt != null ? prompt : DeviceCodePrompt.openBrowser(); return this; } @@ -1190,7 +1192,7 @@ public static final class DiscoveryOptions { private boolean allowInsecureTransport; private String discoveryUrl; private String issuer; - private DeviceCodePrompt prompt = DeviceCodePrompt.SYSTEM_OUT; + private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser(); private ClientTlsConfiguration tlsConfig; /** @@ -1229,12 +1231,13 @@ public DiscoveryOptions issuer(String issuer) { } /** - * Sets how the device code challenge is shown to the user, for example - * {@link DeviceCodePrompt#openBrowser()} to also open the verification URL in a browser. Defaults - * to {@link DeviceCodePrompt#SYSTEM_OUT}. + * 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.SYSTEM_OUT; + this.prompt = prompt != null ? prompt : DeviceCodePrompt.openBrowser(); return this; } 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 index 8604e1cbc..f2c82e65b 100644 --- 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 @@ -49,6 +49,24 @@ public void testOpenIsBestEffortForRejectedUrls() throws Exception { invokeOpen("not a url"); } + @Test + public void testOpenRespectsDisableProperty() throws Exception { + // with the kill-switch off, open() returns before touching the desktop even for a valid http(s) + // URL; this is also what keeps the suite from launching a real browser on a developer machine + String prop = "questdb.client.oidc.open.browser"; + String prev = System.getProperty(prop); + System.setProperty(prop, "false"); + try { + invokeOpen("https://idp.example.com/device?user_code=ABCD"); + } 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 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 index c241de94b..5af6a3965 100644 --- 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 @@ -55,6 +55,13 @@ public class OidcDeviceAuthTest { + static { + // The default device-code prompt opens a browser when one is available. Developer machines have + // one, so disable the launch process-wide for the whole test class; otherwise every flow that + // reaches the prompt (e.g. a fromQuestDB or builder test) would pop a real browser tab. + System.setProperty("questdb.client.oidc.open.browser", "false"); + } + private static final String DEVICE_PATH = "/device"; private static final JsonParser NOOP_JSON_PARSER = (code, tag, position) -> { }; @@ -969,7 +976,7 @@ public void testFromQuestDbDiscoversDeviceEndpointFromIssuer() throws Exception 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(""), new OidcDeviceAuth.DiscoveryOptions().issuer(server.httpUrl("")).allowInsecureTransport(true))) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("")))) { // settings advertise groups.encoded.in.token=true, so getToken() returns the id token Assert.assertEquals("ID-WK", auth.getToken()); } @@ -998,7 +1005,7 @@ public void testFromQuestDbDiscoversFromDiscoveryUrl() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)).allowInsecureTransport(true))) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)))) { Assert.assertEquals("ID-DU", auth.getToken()); } } @@ -1024,7 +1031,7 @@ public void testFromQuestDbDiscoveryDocMissingDeviceEndpointRejected() throws Ex }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().issuer(server.httpUrl("")).allowInsecureTransport(true))) { + 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")); @@ -1081,7 +1088,7 @@ public void testFromQuestDbDiscoveryUrlPinAcceptsOnOriginAdvertisedEndpoints() t }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)).allowInsecureTransport(true))) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)))) { Assert.assertEquals("ID-DUP", auth.getToken()); } Assert.assertFalse("discovery must be skipped when /settings advertises both endpoints", wellKnownHit.get()); @@ -1115,7 +1122,7 @@ public void testFromQuestDbDiscoveryUrlPinRejectsForeignIssuerInDocument() throw "https://attacker.example")); }; try (MockOidcServer server = new MockOidcServer(handler)) { - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)).allowInsecureTransport(true))) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)))) { Assert.fail("expected the discoveryUrl pin to reject a document declaring a foreign issuer"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origin than the pinned discovery url")); @@ -1137,7 +1144,7 @@ public void testFromQuestDbDiscoveryUrlPinRejectsOffOriginAdvertisedEndpoints() }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().discoveryUrl("https://trusted-idp.example/.well-known/openid-configuration").allowInsecureTransport(true))) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl("https://trusted-idp.example/.well-known/openid-configuration"))) { Assert.fail("expected the discoveryUrl pin to reject the off-origin endpoints"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); @@ -1159,7 +1166,7 @@ public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.attacker.example").allowInsecureTransport(true))) { + 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("does not match the issuer origin")); @@ -1709,7 +1716,7 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc // 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, new OidcDeviceAuth.DiscoveryOptions().issuer(server.httpUrl("")).allowInsecureTransport(true))) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(questdbUrl, insecure().issuer(server.httpUrl("")))) { Assert.assertNotNull(auth); } } @@ -2672,9 +2679,11 @@ private static String deviceAuthorizationJson(int interval, int expiresIn) { + "}"; } - // DiscoveryOptions permitting insecure http, the common shape for tests reaching a plaintext mock server + // 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); + return new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true).prompt(noopPrompt()); } // isLoopbackHost is a private static security classifier (it gates the plaintext-channel MITM pin); the diff --git a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java index 5c698b505..2ca102b95 100644 --- a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -8,17 +8,19 @@ * (a remote notebook kernel, a container, a headless job) using the OAuth 2.0 Device * Authorization Grant, then shows the three ways to use the resulting token. *

    - * On first use this prints a verification URL and a short code; open the URL in any - * browser (your laptop or your phone) and enter the code. The token is then cached in - * memory and refreshed silently, so re-running this does not prompt again. + * On first use this prints a verification URL and a short code and, on a machine with a + * browser, opens the URL for you; otherwise open it on any device (your laptop or your + * phone) and enter the code. The token is then cached in memory and refreshed silently, + * so re-running this does not prompt again. */ public class OidcDeviceFlowExample { public static void main(String[] args) { // Discover client id, scope, endpoints and the groups-in-token mode from the server. // Alternatively, configure the identity provider explicitly with OidcDeviceAuth.builder(). - // On a local terminal, also open the verification URL in a browser by passing options: + // The default prompt prints the URL and code AND opens the URL in your browser when one is + // available (best-effort; skipped on a headless host). To print only, pass options: // import io.questdb.client.cutlass.auth.DeviceCodePrompt; - // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.openBrowser())) + // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT)) try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { auth.getToken(); // sign in once (prompts on first use, then caches and refreshes silently) From 2126e22d320fc3f79613418d496ecb9489075f65 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 13:46:38 +0100 Subject: [PATCH 031/192] Send OIDC audience on device and refresh requests SettingsDiscoveryParser now reads acl.oidc.audience from the trusted config object, and fromQuestDB threads it into the builder, so the audience is discovered from the server rather than only set through builder(). tryRefresh() now appends the audience form parameter - the device authorization request already sent it - so both the device grant and the refresh request carry it, matching the Python client. The device-code poll does not, also matching Python. Tests: testDiscoveryReadsAudience and testAudienceSentOnRefresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- .../client/cutlass/auth/OidcDeviceAuth.java | 17 ++++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 66 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b61e5a108..02d1338cf 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ OidcDeviceAuth auth = OidcDeviceAuth.builder() .build(); ``` -Discovery via `fromQuestDB(...)` reads the OIDC client id, scope and endpoints from the server's `/settings`, and the identity provider's client must have the device authorization grant enabled. When the server does not advertise its device authorization endpoint (today's servers), pin the identity provider by its issuer so the client can discover the endpoint from the issuer's `.well-known/openid-configuration` document: +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( 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 index 8d99c3aad..7fa96d91e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -319,6 +319,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt .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) .issuer(resolvedIssuer) .allowInsecureTransport(allowInsecureTransport) @@ -1009,6 +1010,9 @@ private boolean tryRefresh() { if (scope != null) { appendParam(formSink, "scope", scope); } + if (audience != null) { + appendParam(formSink, "audience", audience); + } tokenParser.clear(); try { @@ -1073,8 +1077,10 @@ public Builder allowInsecureTransport(boolean allowInsecureTransport) { } /** - * Sets the {@code audience} (or {@code resource}) request parameter. Some identity providers - * require it so the issued token carries the {@code aud} claim QuestDB expects. Optional. + * 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; @@ -1436,6 +1442,7 @@ static Endpoint parse(String url) { } 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; @@ -1443,6 +1450,7 @@ private static final class SettingsDiscoveryParser implements JsonParser { 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(); @@ -1489,6 +1497,8 @@ public void onEvent(int code, CharSequence tag, int position) { 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; } @@ -1517,6 +1527,9 @@ public void onEvent(int code, CharSequence tag, int position) { case FIELD_GROUPS_IN_TOKEN: groupsInToken = Chars.equals("true", tag); break; + case FIELD_AUDIENCE: + putNonNull(audience, tag); + break; default: break; } 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 index 5af6a3965..108b17876 100644 --- 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 @@ -118,6 +118,38 @@ public void testAudienceParameterSentToDeviceEndpoint() throws Exception { }); } + @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") + .clockSkewSeconds(120) // larger than the 60s token lifetime, so getToken() refreshes + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + Assert.assertEquals("ACCESS-1", auth.getToken()); + Assert.assertEquals("ACCESS-2", auth.getToken()); + Assert.assertTrue(refreshBody.get(), refreshBody.get().contains("audience=api%3A%2F%2Fquestdb")); + } + }); + } + @Test(timeout = 30_000) public void testBuilderIssuerPinAcceptsMatchingOrigin() throws Exception { assertMemoryLeak(() -> { @@ -712,6 +744,40 @@ public void testDiscoveryIgnoresPreferencesKeys() throws Exception { }); } + @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.getToken()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb")); + } + } + }); + } + @Test(timeout = 30_000) public void testDiscoveryRejectsMissingClientId() throws Exception { assertMemoryLeak(() -> { From b8f073eb17c88f6dd6eb61cb9cb6837abc23a408 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 14:56:05 +0100 Subject: [PATCH 032/192] Tighten OIDC IdP transport and issuer-path trust The identity provider endpoints (device authorization, token, and the .well-known discovery URL) now require https unless they are loopback, regardless of allowInsecureTransport. The flag relaxes only the QuestDB /settings link; it no longer downgrades the identity provider, so the device code and refresh token are never sent in cleartext. Loopback http endpoints are accepted without the flag, for local development. When the pinned issuer carries a path, an endpoint from /settings must now be under that path, not just on the issuer's origin. A path-based multi-tenant provider (Keycloak /realms/) shares one origin per tenant, so the origin check alone could not stop a tampered /settings from steering credentials to a different realm. The check decodes repeatedly (%252e -> ..), folds backslashes, scans matrix params, and rejects any . or .. segment. Endpoints from IdP discovery or configured explicitly are not scoped, since some providers place endpoints outside the issuer path. Both changes match the behaviour of the Python client (py #133). Tests: testIdpEndpointsRequireHttpsExceptLoopback and three testIssuerPathScoping* tests; OidcDeviceAuthTest (95) and BrowserLauncherTest (4) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- .../client/cutlass/auth/OidcDeviceAuth.java | 179 ++++++++++++++++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 105 +++++++++- 3 files changed, 265 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 02d1338cf..951ab6a96 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,9 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( } ``` -By default the device authorization and token endpoints must use `https`, so tokens are never sent in cleartext; an `http` endpoint is rejected. For local development against an `http` endpoint, opt in explicitly with `.allowInsecureTransport(true)` on the builder, or `OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true))`. +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 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. +`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin (and, when the issuer has a path, an endpoint advertised by `/settings` must also be under that path — so a tampered `/settings` cannot redirect to a different tenant on a path-based provider such as Keycloak `…/realms/{realm}`), and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered `/settings` cannot move it. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` (optionally with `.issuer(...)`) instead of discovering it. ### Explicit Timestamps 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 index 7fa96d91e..840e3b618 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -251,6 +251,21 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt .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 @@ -506,9 +521,7 @@ private static void discoverFromIdp(String issuer, String discoveryUrl, ClientTl // and the credential POSTs it resolves - are aimed String url = discoveryUrl != null ? discoveryUrl : wellKnownUrl(issuer); Endpoint endpoint = Endpoint.parse(url); - if (!allowInsecureTransport) { - requireSecureTransport(endpoint.isTls, "OIDC issuer / discovery url", url); - } + requireSecureIdpEndpoint(endpoint, "OIDC issuer / discovery url", 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"); @@ -574,6 +587,114 @@ private static boolean isDottedIpv4(String host) { return octets == 4 && digits > 0 && value <= 255; } + 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 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 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; + } + + 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[] endpointSegs = decodePathSegments(pathOnly(endpointUrl)); + // 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++) { + if (".".equals(endpointSegs[i]) || "..".equals(endpointSegs[i])) { + 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 String pathOnly(String url) { + // the path component only (drop any ?query / #fragment); a ;matrix parameter stays part of the path, + // so a traversal hidden in it (.../token;..%2f..) is still scanned + String path = Endpoint.parse(url).path; + for (int i = 0, n = path.length(); i < n; i++) { + char c = path.charAt(i); + if (c == '?' || c == '#') { + return path.substring(0, i); + } + } + return 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 boolean isLoopbackHost(String host) { // loopback traffic never leaves the host, so a plaintext /settings fetch to it has no network // interception risk; match localhost and the whole IPv4 127.0.0.0/8 block @@ -625,6 +746,23 @@ private static void putNonNull(StringSink sink, CharSequence tag) { } } + 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() @@ -1067,9 +1205,11 @@ private Builder() { } /** - * Permits insecure {@code http} (rather than {@code https}) for the device authorization and - * token endpoints. Tokens then travel in cleartext, so this is rejected by default; enable only - * for local development on a trusted network. Defaults to {@code false}. + * 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; @@ -1103,10 +1243,8 @@ public OidcDeviceAuth build() { Endpoint deviceEndpoint = Endpoint.parse(deviceAuthorizationEndpoint); Endpoint parsedTokenEndpoint = Endpoint.parse(tokenEndpoint); Endpoint issuerEndpoint = issuer != null && !issuer.isEmpty() ? Endpoint.parse(issuer) : null; - if (!allowInsecureTransport) { - requireSecureTransport(deviceEndpoint.isTls, "device authorization endpoint", deviceAuthorizationEndpoint); - requireSecureTransport(parsedTokenEndpoint.isTls, "token endpoint", tokenEndpoint); - } + 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); @@ -1153,8 +1291,11 @@ public Builder httpTimeoutMillis(int httpTimeoutMillis) { * {@code https://idp.example.com}). When set, {@link #build()} rejects a token or device * authorization endpoint not on this origin, so a compromised or tampered configuration cannot * redirect the device code and refresh token to an attacker. {@link #fromQuestDB(String, DiscoveryOptions)} - * sets it for you when discovering from a server. A provider hosting its endpoints on a different - * origin than its issuer is rejected when pinned; configure it without an issuer. Optional. + * sets it for you when discovering from a server, and additionally requires each endpoint advertised + * by {@code /settings} to be under the issuer's path (not just its origin), so a tampered + * {@code /settings} cannot redirect credentials to a different tenant on a path-based provider (for + * example a Keycloak realm path like {@code /realms/acme}). A provider hosting its endpoints on a + * different origin than its issuer is rejected when pinned; configure it without an issuer. Optional. */ public Builder issuer(String issuer) { this.issuer = issuer; @@ -1202,9 +1343,10 @@ public static final class DiscoveryOptions { private ClientTlsConfiguration tlsConfig; /** - * Permits insecure {@code http} for both the QuestDB server and the discovered identity provider - * endpoints. Tokens and the device code then travel in cleartext, so this is rejected by default; - * enable only for local development on a trusted network. Defaults to {@code false}. + * 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; @@ -1228,8 +1370,11 @@ public DiscoveryOptions discoveryUrl(String discoveryUrl) { * 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 pins the token and device authorization endpoints, - * so any endpoint not on the issuer origin is rejected. A provider hosting its endpoints on a - * different origin than its issuer must be configured without an issuer. Optional. + * so any endpoint not on the issuer origin is rejected. When the issuer has a path, an endpoint + * advertised by {@code /settings} must also be under that path, so a tampered {@code /settings} + * cannot redirect credentials to a different tenant on a path-based provider (for example a Keycloak + * realm path like {@code /realms/acme}). A provider hosting its endpoints on a different origin than + * its issuer must be configured without an issuer. Optional. */ public DiscoveryOptions issuer(String issuer) { this.issuer = issuer; 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 index 108b17876..34ef78a7f 100644 --- 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 @@ -1645,9 +1645,10 @@ public void testIncompleteDeviceResponseRejected() throws Exception { } @Test(timeout = 30_000) - public void testInsecureEndpointsRejectedUnlessOptedIn() throws Exception { + public void testIdpEndpointsRequireHttpsExceptLoopback() throws Exception { assertMemoryLeak(() -> { - // http endpoints carry tokens in cleartext; the client must refuse them unless the caller opts in + // 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") @@ -1670,7 +1671,9 @@ public void testInsecureEndpointsRejectedUnlessOptedIn() throws Exception { Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint")); Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http")); } - // opting in allows http, for local development + // 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") @@ -1678,7 +1681,101 @@ public void testInsecureEndpointsRejectedUnlessOptedIn() throws Exception { .allowInsecureTransport(true) .build() ) { - // accepted: http endpoints are allowed once insecure transport is opted in + 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.getToken()); + } + } + }); + } + + @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 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")); + } } }); } From 0a31d4924dad318a6ab9eb69664cce181af81f0c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 15:11:54 +0100 Subject: [PATCH 033/192] Clamp OIDC device-code lifetime to 600s/1800s The device-code lifetime clamp now matches the Python client. A missing or zero expires_in in the device-authorization response defaults to 600s (was 300s), and an absurd value is capped at 1800s (was 3600s) via a new MAX_DEVICE_CODE_TTL_SECONDS, so a hostile or buggy provider cannot make the client poll for an absurd duration. The token-cache clamp is unchanged (300s default, 3600s cap); it previously shared the cap constant with the device-code clamp, now split so the two are independent. Test: testDeviceCodeLifetimeClamped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 13 +++++--- .../test/cutlass/auth/OidcDeviceAuthTest.java | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) 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 index 840e3b618..c0ae2c93d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -99,8 +99,8 @@ public class OidcDeviceAuth implements QuietCloseable { static final String GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"; static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; private static final int DEFAULT_CLOCK_SKEW_SECONDS = 30; - // device code TTL when the device authorization response omits expires_in - private static final int DEFAULT_DEVICE_CODE_TTL_SECONDS = 300; + // 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 @@ -117,8 +117,11 @@ public class OidcDeviceAuth implements QuietCloseable { // abort polling after this many consecutive transport failures instead of silently retrying // until the device code expires private static final int MAX_CONSECUTIVE_POLL_ERRORS = 3; - // upper bounds on the provider-reported expires_in / interval, so an absurd or hostile value - // cannot overflow the poll timing arithmetic or make the client wait absurdly long + // 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; + // 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; private static final int MAX_POLL_INTERVAL_SECONDS = 300; // cap bytes drained per response so a hostile/MITM'd server cannot stream an endless body and @@ -1072,7 +1075,7 @@ private void runDeviceFlow() { } final String deviceCode = deviceAuthParser.deviceCode.toString(); - final int expiresInSeconds = boundedSeconds(deviceAuthParser.expiresIn, DEFAULT_DEVICE_CODE_TTL_SECONDS, MAX_EXPIRES_IN_SECONDS); + 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( sanitizeForDisplay(deviceAuthParser.userCode.toString()), 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 index 34ef78a7f..3c7f86b43 100644 --- 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 @@ -543,6 +543,39 @@ public void testConcurrentGetTokenStartsSingleSignIn() throws Exception { }); } + @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.getToken()); + 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.getToken()); + Assert.assertEquals(1800, shown.get().getExpiresInSeconds()); + } + }); + } + @Test(timeout = 30_000) public void testDeviceEndpointReturnsOauthError() throws Exception { assertMemoryLeak(() -> { From 28dc110517e5bb96f4ca6ba3b9cd3b01f2467ad3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 15:32:33 +0100 Subject: [PATCH 034/192] reduce max poll interval to 60s --- .../java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java | 2 +- .../questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index c0ae2c93d..998aabbe9 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -123,7 +123,7 @@ public class OidcDeviceAuth implements QuietCloseable { // 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; - private static final int MAX_POLL_INTERVAL_SECONDS = 300; + 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; 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 index 3c7f86b43..0a5cafc7d 100644 --- 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 @@ -1446,7 +1446,9 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti if (body.contains("grant_type=refresh_token")) { refreshInFlight.countDown(); try { - releaseRefresh.await(20, TimeUnit.SECONDS); + if (!releaseRefresh.await(30, TimeUnit.SECONDS)) { + Assert.fail("token refresh timeout expired"); + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } From 8e721d49fbbf192a0e37d3237fe2d3a46a79fd0a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 16:51:23 +0100 Subject: [PATCH 035/192] Treat OIDC token-poll 429 as a transient backoff pollOnce now maps an HTTP 429 to POLL_SLOW_DOWN before the error and transport-error classification, so a rate-limited identity provider grows the poll interval (capped at 60s) and keeps polling, like slow_down, instead of charging the MAX_CONSECUTIVE_POLL_ERRORS budget and failing fast. Matches the Python client. Also documents MAX_POLL_INTERVAL_SECONDS (reduced to 60s in the preceding commit), which now also bounds the slow_down/429 growth. New tests: a 429 keeps polling to the device-code deadline instead of aborting with "repeated unexpected responses", and the IdP-reported interval is clamped to 60s. OidcDeviceAuthTest (98) and BrowserLauncherTest (4) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 11 ++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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 index 998aabbe9..0392d9170 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -108,6 +108,8 @@ public class OidcDeviceAuth implements QuietCloseable { private static final String ERROR_AUTHORIZATION_PENDING = "authorization_pending"; private static final String ERROR_SLOW_DOWN = "slow_down"; private static final HttpClientConfiguration HTTP_CONFIG = DefaultHttpClientConfiguration.INSTANCE; + // 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 @@ -123,6 +125,8 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 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 @@ -968,6 +972,13 @@ private int pollOnce(String deviceCode) { // persistent failure rather than swallowing it as a pending authorization postForm(tokenEndpoint, tokenParser); + // A rate-limited identity provider answers 429; 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 charging the transport-error budget, 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.2: an error response is an error even if the body also carries a token, so handle the // OAuth error first - a token smuggled alongside an error must never count as a grant if (tokenParser.error.length() > 0) { 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 index 0a5cafc7d..2f0fffe46 100644 --- 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 @@ -2156,6 +2156,58 @@ public void testOversizedSettingsBodyAbortsAtSizeCap() throws Exception { }); } + @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)) { + try { + auth.getToken(); + Assert.fail("expected the device code to expire"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + } + Assert.assertEquals(60, shown.get().getIntervalSeconds()); + } + }); + } + + @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 + // transport error that fails fast after MAX_CONSECUTIVE_POLL_ERRORS. The token endpoint always + // returns 429, so the flow ends only when the short-lived device code expires - proving polling + // continued past the 3-error budget rather than aborting with "repeated unexpected responses". + 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())) { + try { + auth.getToken(); + Assert.fail("expected the device code to expire while the token endpoint kept returning 429"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("repeated unexpected responses")); + } + } + }); + } + @Test(timeout = 30_000) public void testPersistentTransportFailureDuringPollingAborts() throws Exception { assertMemoryLeak(() -> { From 62403f37015e80812bd42353e8a5d67a9e350693 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 17:12:37 +0100 Subject: [PATCH 036/192] Remove OIDC poll-error budget; match Python model Token polling no longer aborts after a fixed number of consecutive transport errors; MAX_CONSECUTIVE_POLL_ERRORS and its counter are gone. Matching the Python client, the poll loop now keeps polling to the device-code deadline on any transient failure - a network blip, a non-JSON or garbled body, a 5xx, or a 429 - and fails fast only on a terminal response: a 4xx other than 429, a well-formed OAuth error, a malformed status line, or a 2xx without a usable token. pollOnce classifies a non-2xx with no OAuth error via the new isHttpStatusTransient() / isHttpStatusTerminal4xx() helpers (5xx keeps polling, 4xx is terminal). pollForToken retries transient responses and rethrows terminal ones; a garbled body is transient unless its status is a terminal 4xx. Tradeoff: a persistently unreachable or broken token endpoint now polls until the device code expires (clamped) instead of failing fast. Tests: the abort test now asserts polling continues to the deadline; new 5xx-keeps-polling and terminal-4xx-fails-fast tests; the malformed-body tests use a terminal 4xx so the parse rejection surfaces. OidcDeviceAuthTest (100) and BrowserLauncherTest (4) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 83 +++++++++-------- .../test/cutlass/auth/OidcDeviceAuthTest.java | 89 ++++++++++++++----- 2 files changed, 109 insertions(+), 63 deletions(-) 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 index 0392d9170..bec2cbabe 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -116,9 +116,6 @@ public class OidcDeviceAuth implements QuietCloseable { // 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; - // abort polling after this many consecutive transport failures instead of silently retrying - // until the device code expires - private static final int MAX_CONSECUTIVE_POLL_ERRORS = 3; // 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; @@ -907,10 +904,20 @@ private boolean isHttpStatusSuccess() { return responseStatus.length() > 0 && responseStatus.charAt(0) == '2'; } + private boolean isHttpStatusTerminal4xx() { + // a 4xx other than 429 is a terminal client-error rejection (429 is a transient rate-limit) + return responseStatus.length() > 0 && 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. + return responseStatus.length() > 0 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus)); + } + private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) { final long deadlineNanos = System.nanoTime() + expiresInSeconds * 1_000_000_000L; long intervalMillis = (long) intervalSeconds * 1000L; - int consecutiveTransportErrors = 0; while (true) { throwIfClosed(); // check the deadline before polling so an expiry that elapsed during the previous sleep aborts @@ -923,35 +930,22 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS if (result == POLL_SUCCESS) { return; } - if (result == POLL_TRANSIENT_ERROR) { - // a non-2xx with no parseable answer; charge the transport-error budget so a - // persistently failing token endpoint aborts instead of polling until the code expires - if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { - throw new OidcAuthException().put("the token endpoint returned repeated unexpected responses [httpStatus=").put(responseStatus).put(']'); - } - } else { - consecutiveTransportErrors = 0; - if (result == POLL_SLOW_DOWN) { - // grow the interval per RFC 8628, capped at the same bound as the initial value so - // repeated slow_down responses cannot inflate the wait without bound - intervalMillis = Math.min(intervalMillis + SLOW_DOWN_INCREMENT_SECONDS * 1000L, MAX_POLL_INTERVAL_SECONDS * 1000L); - } + 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 brief network blip is fine to retry, but a persistent failure (rejected TLS cert, - // refused connection, unresolvable host) must surface with its cause rather than - // masquerade as a device-code timeout - if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { - throw new OidcAuthException(e).put("the token endpoint became unreachable while waiting for authorization"); - } + // 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 a transport-class blip, retried on - // the same budget; a well-formed OAuth error or unexpected response (no parse cause) is a - // real answer from the identity provider and aborts immediately - if (!(e.getCause() instanceof JsonException)) { - throw e; - } - if (++consecutiveTransportErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + // 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; } } @@ -968,13 +962,13 @@ private int pollOnce(String deviceCode) { appendParam(formSink, "client_id", clientId); tokenParser.clear(); - // a transport failure here propagates to pollForToken, which retries a brief blip but aborts on a - // persistent failure rather than swallowing it as a pending authorization + // 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); // A rate-limited identity provider answers 429; 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 charging the transport-error budget, so transient rate limiting does not fail the sign-in. + // 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; } @@ -990,21 +984,24 @@ private int pollOnce(String deviceCode) { } throw OidcAuthException.oauthError(tokenParser.error, tokenParser.errorDescription); } - // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx status is - // malformed or hostile - charge the transport-error budget rather than trust it - if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { - if (isHttpStatusSuccess()) { + // 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); return POLL_SUCCESS; } - return POLL_TRANSIENT_ERROR; - } - // no tokens and no OAuth error: a 2xx is a definitive but malformed answer and aborts; a non-2xx - // (gateway 5xx, empty body) is a transport-class blip - retry rather than abort the sign-in - if (isHttpStatusSuccess()) { + // 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(']'); } - return POLL_TRANSIENT_ERROR; + // 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) { 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 index 2f0fffe46..968b91621 100644 --- 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 @@ -2186,9 +2186,8 @@ public void testPollIntervalClampedTo60() throws Exception { public void testRateLimitedTokenEndpointBacksOffInsteadOfFailingFast() throws Exception { assertMemoryLeak(() -> { // HTTP 429 is a transient backoff (poll slower, keep polling), matching the Python client, not a - // transport error that fails fast after MAX_CONSECUTIVE_POLL_ERRORS. The token endpoint always - // returns 429, so the flow ends only when the short-lived device code expires - proving polling - // continued past the 3-error budget rather than aborting with "repeated unexpected responses". + // 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)); @@ -2202,22 +2201,23 @@ public void testRateLimitedTokenEndpointBacksOffInsteadOfFailingFast() throws Ex Assert.fail("expected the device code to expire while the token endpoint kept returning 429"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); - Assert.assertFalse(e.getMessage(), e.getMessage().contains("repeated unexpected responses")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request")); } } }); } @Test(timeout = 30_000) - public void testPersistentTransportFailureDuringPollingAborts() throws Exception { + public void testPersistentTransportFailureKeepsPollingToDeadline() throws Exception { assertMemoryLeak(() -> { // the device endpoint works, but the (co-located) token endpoint drops the connection on every - // poll; polling must abort with the underlying transport error after a few attempts, not retry - // silently until the code expires. The endpoints share one origin so the build-time co-location - // check passes - the mock simulates the unreachable token endpoint by dropping the connection + // 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, 10)); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 3)); } return MockOidcServer.dropConnection(); }; @@ -2230,11 +2230,59 @@ public void testPersistentTransportFailureDuringPollingAborts() throws Exception .prompt(noopPrompt()) .build()) { auth.getToken(); - Assert.fail("expected a transport failure to abort polling"); + 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())) { + try { + auth.getToken(); + Assert.fail("expected the device code to expire while the token endpoint returned 503"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + 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())) { + try { + auth.getToken(); + Assert.fail("expected a terminal 4xx to fail fast"); } catch (OidcAuthException e) { - // surfaces the transport failure, not the device-code-expired timeout - Assert.assertFalse(e.getMessage(), e.getMessage().contains("timed out")); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("unreachable")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("device code expired")); } } }); @@ -2565,9 +2613,9 @@ public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception if (DEVICE_PATH.equals(path)) { return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); } - // a 200 that carries a token but is malformed JSON: the parser fails, and the raw body + // 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(200, "{\"access_token\":\"" + secret + "\" not-valid-json}"); + return MockOidcServer.json(400, "{\"access_token\":\"" + secret + "\" not-valid-json}"); }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { @@ -2624,8 +2672,8 @@ public void testTokenResponseExpiresInIsClamped() throws Exception { 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 - it charges the - // response to the transport-error budget and aborts rather than trusting the token + // 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)); @@ -2639,7 +2687,7 @@ public void testTokenUnderNonSuccessStatusIsNotAccepted() throws Exception { Assert.fail("expected a token under a 400 to be rejected, not accepted"); } catch (OidcAuthException e) { Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED")); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("repeated unexpected responses")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); } } }); @@ -2718,12 +2766,13 @@ public void testTruncatedSettingsResponseRejected() throws Exception { 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 + // 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(200, "{\"access_token\":\"abc"); + return MockOidcServer.json(400, "{\"access_token\":\"abc"); }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { From f0cd84fee3230d40e6c9f05031a5c20838624592 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 18:40:59 +0100 Subject: [PATCH 037/192] Accept token provider over WebSocket transport httpTokenProvider was an HTTP-only feature: the WebSocket build path rejected it, and only a fixed httpToken or username/password worked, captured once as a static Authorization header at connect time. A long-lived WebSocket sender could therefore not follow a rotating token (e.g. an OIDC device-flow token). The WebSocket sender now holds a Supplier for the upgrade Authorization header and evaluates it on every handshake - the initial connect and each reconnect - so a refreshing provider (auth::getTokenSilently) presents a freshly pulled token each time the link is (re)established. An already-established socket is not re-authenticated mid-stream; the provider is queried at handshake time, not per data frame. The fixed-token and username/password paths become constant suppliers, unchanged in behavior. QwpWebSocketSender evaluates the supplier inside buildAndConnect's per-endpoint try, so a provider that throws (a failed silent refresh) is handled as a connect failure for that attempt and retried within the reconnect budget rather than escaping the I/O thread. Initial connect still fails loudly. Tests: TestWebSocketServer captures the upgrade Authorization header (pollAuthorizationHeader); new WebSocketTokenProviderTest covers the token on the initial upgrade, re-query on reconnect, and the fixed token / username-password regression paths; SenderBuilderErrorApiTest now asserts TCP/UDP reject the provider while WebSocket accepts it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 16 +- .../main/java/io/questdb/client/Sender.java | 42 +++- .../qwp/client/QwpWebSocketSender.java | 31 ++- .../test/SenderBuilderErrorApiTest.java | 19 +- .../client/WebSocketTokenProviderTest.java | 225 ++++++++++++++++++ .../qwp/websocket/TestWebSocketServer.java | 21 +- 6 files changed, 321 insertions(+), 33 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index 9a23f8925..c58ee98a0 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -25,14 +25,16 @@ package io.questdb.client; /** - * Supplies an HTTP authentication token to a {@link Sender} on demand. The sender calls - * {@link #getToken()} as it builds each request, so a provider returning a freshly refreshed token - * - e.g. {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender authenticated as the - * token rotates, without rebuilding it. + * Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a + * freshly refreshed token - e.g. {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender + * authenticated as the token rotates, without rebuilding it. Over HTTP the sender calls + * {@link #getToken()} as it builds each request; over WebSocket it calls it once per connection + * handshake, on the initial connect and again on every reconnect. *

    - * {@link #getToken()} runs on the sender's flush path: it must return promptly and must not block on - * interactive input. A quick silent token refresh is fine, but it must not start an interactive - * sign-in. An exception from {@link #getToken()} fails the current flush. + * {@link #getToken()} runs on the sender's flush and reconnect paths: it must return promptly and must + * not block on interactive input. A quick silent token refresh is fine, but it must not start an + * interactive sign-in. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the + * connection attempt (WebSocket). * * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider) */ diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 26f0c8a55..c6dc22845 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -66,6 +66,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. @@ -1380,7 +1381,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) { @@ -2014,12 +2015,15 @@ public LineSenderBuilder httpToken(String token) { * 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::getTokenSilently)}. *
    - * The provider is not called at build time: the first call happens when the first row is started, - * then once per flush. A lazily-signing-in provider can therefore be wired before the interactive - * sign-in completes, as long as a token is obtainable before the first row - otherwise that row - * fails. Running on the flush path, the provider must return promptly and must not block on - * interactive input (see {@link HttpTokenProvider}). HTTP transport only, and mutually exclusive - * with {@link #httpToken(String)} and {@link #httpUsernamePassword(String, String)}. + * The provider is not called at build time. Over HTTP the first call happens when the first row is + * started, then once per flush. Over WebSocket the provider is queried once per connection handshake - + * on the initial connect and again on every reconnect - so a refreshed token is presented each time the + * link is (re)established; an already-established WebSocket is not re-authenticated mid-stream. A + * lazily-signing-in provider can therefore be wired before the interactive sign-in completes, as long + * as a token is obtainable before the first connect/row - otherwise that connect or row fails. Running + * on the send/flush and reconnect paths, the provider must return promptly and must not block on + * interactive input (see {@link HttpTokenProvider}). Supported over HTTP and WebSocket transport, and + * mutually exclusive with {@link #httpToken(String)} and {@link #httpUsernamePassword(String, String)}. * * @param httpTokenProvider supplies the current HTTP authentication token * @return this instance for method chaining @@ -2832,13 +2836,28 @@ private void addAddressEntry(CharSequence src, int start, int end, int defaultPo ports.add(effectivePort); } - private String buildWebSocketAuthHeader() { + private Supplier buildWebSocketAuthHeader() { if (username != null && password != null) { String credentials = username + ":" + password; - return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + String header = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return () -> header; } if (httpToken != null) { - return "Bearer " + httpToken; + String header = "Bearer " + httpToken; + return () -> header; + } + if (httpTokenProvider != null) { + // pull a fresh token at each (re)handshake so a long-lived WebSocket follows token + // refreshes; reject a null/empty/blank return (forbidden by the HttpTokenProvider + // contract) rather than send a malformed "Bearer " header the server only 401s on + final HttpTokenProvider provider = httpTokenProvider; + return () -> { + CharSequence token = provider.getToken(); + if (Chars.isBlank(token)) { + throw new LineSenderException("token provider returned a null or empty token"); + } + return "Bearer " + token; + }; } return null; } @@ -3549,9 +3568,6 @@ private void validateParameters() { if (httpToken != null && (username != null || password != null)) { throw new LineSenderException("cannot use both token and username/password authentication"); } - if (httpTokenProvider != null) { - throw new LineSenderException("HTTP token provider authentication is not supported for WebSocket protocol"); - } if (httpPath != null) { throw new LineSenderException("HTTP path is not supported for WebSocket protocol"); } 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 e34a19235..979bfe552 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 @@ -71,6 +71,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; /** * QWP v1 WebSocket client sender for streaming data to QuestDB. @@ -134,7 +135,12 @@ 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 inside buildAndConnect's per-endpoint try, so a throwing + // provider (e.g. a failed silent refresh) is handled as a connect failure rather than escaping. + private final Supplier authorizationHeaderSupplier; private final int autoFlushBytes; private final long autoFlushIntervalNanos; // Auto-flush configuration @@ -274,14 +280,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 = List.copyOf(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<>(); @@ -566,7 +572,7 @@ public static QwpWebSocketSender connect( boolean gorillaEnabled ) { return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeader, + autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), requestDurableAck, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, @@ -585,7 +591,7 @@ public static QwpWebSocketSender connect( int autoFlushRows, int autoFlushBytes, long autoFlushIntervalNanos, - String authorizationHeader, + Supplier authorizationHeaderSupplier, boolean requestDurableAck, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, @@ -604,7 +610,7 @@ public static QwpWebSocketSender connect( QwpWebSocketSender sender = new QwpWebSocketSender( endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, - authorizationHeader + authorizationHeaderSupplier ); try { sender.requestDurableAck = requestDurableAck; @@ -656,7 +662,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) ); } @@ -2301,6 +2307,10 @@ private static Throwable captureCloseError(Throwable terminalError, Throwable t) return terminalError; } + private static Supplier fixedAuthHeader(String header) { + return header == null ? null : () -> header; + } + private static long maskGeoHashBits(long value, int precisionBits) { return precisionBits >= 64 ? value : value & ((1L << precisionBits) - 1L); } @@ -2440,7 +2450,12 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) { newClient.setQwpRequestDurableAck(requestDurableAck); newClient.connect(ep.host, ep.port); int upgradeTimeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); - newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authorizationHeader); + // Pull the current Authorization header for this handshake. For an httpTokenProvider + // this re-queries the provider, so a reconnect presents a freshly refreshed token. A + // provider that throws here (a failed silent refresh) is caught below as a connect + // failure for this endpoint and retried within the reconnect budget. + String authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get(); + newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authHeader); } catch (HttpClientException e) { HttpClientException classified = QwpUpgradeFailures.classify(newClient, ep.host, ep.port, e); newClient.close(); 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 3cd47996a..fb7b844df 100644 --- a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java +++ b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java @@ -265,11 +265,24 @@ public void testHttpTokenProviderIsMutuallyExclusiveWithOtherAuth() { } @Test - public void testHttpTokenProviderRejectedForNonHttpTransport() { - // the provider is an HTTP-only feature; every non-HTTP transport must reject it at build time + 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"); - assertProviderRejected(Sender.Transport.WEBSOCKET, "token provider authentication is not supported for WebSocket protocol"); } private static void assertProviderRejected(Sender.Transport transport, String expectedMessage) { 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..8e81076ef --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java @@ -0,0 +1,225 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.test.cutlass.qwp.websocket.TestWebSocketServer; +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.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 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. + */ +public class WebSocketTokenProviderTest { + + @Test + public void testProviderRequeriedOnEveryReconnect() throws Exception { + // 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 { + 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 { + // 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 testUsernamePasswordStillSuppliedOverWebSocket() throws Exception { + // 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); + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java index 4db34d447..db1a59e5a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java @@ -42,8 +42,10 @@ import java.security.MessageDigest; import java.util.Base64; import java.util.List; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -54,6 +56,9 @@ public class TestWebSocketServer implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(TestWebSocketServer.class); private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + // Authorization header value captured from each well-formed upgrade request ("" when absent), in + // arrival order. Tests poll this to assert the token a provider supplied at each (re)handshake. + private final BlockingQueue capturedAuthHeaders = new LinkedBlockingQueue<>(); private final List clients = new CopyOnWriteArrayList<>(); private final boolean emitDurableAckHeader; private final WebSocketServerHandler handler; @@ -164,6 +169,14 @@ public int getPort() { return port; } + /** + * Authorization header value seen on the next upgrade handshake ("" if the request carried none), + * in arrival order. Blocks up to the timeout for a handshake to arrive; returns null on timeout. + */ + public String pollAuthorizationHeader(long timeout, TimeUnit unit) throws InterruptedException { + return capturedAuthHeaders.poll(timeout, unit); + } + /** * Replaces the advertised role for subsequent handshakes (live update). */ @@ -433,16 +446,20 @@ private boolean performHandshake() throws IOException { } String key = null; + String authorization = ""; for (String line : request.toString().split("\r\n")) { - if (line.toLowerCase().startsWith("sec-websocket-key:")) { + String lower = line.toLowerCase(); + if (lower.startsWith("sec-websocket-key:")) { key = line.substring(18).trim(); - break; + } else if (lower.startsWith("authorization:")) { + authorization = line.substring("authorization:".length()).trim(); } } if (key == null) { return false; } + capturedAuthHeaders.add(authorization); // Arbitrary-status reject path: tests use setRejectWithStatus // to drive the failover loop's terminal-vs-transient From 63487c1c25f629f04cbb0d346a4a197b720368c6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 23 Jun 2026 19:13:27 +0100 Subject: [PATCH 038/192] Make OIDC clock skew fixed and lifetime-capped The OIDC device-flow client's clock skew - the margin by which a cached token is treated as expired early, to absorb clock drift and request latency - was a configurable builder option defaulting to 30s with a flat subtraction. This matches it to the Python client (questdb.auth): a fixed 30s, no longer configurable, and capped at half the token lifetime. The cap (effectiveSkewMillis = min(30s, tokenTtlMillis / 2)) stops a short-lived token from being reported expired the instant it is issued - a flat 30s skew marks any sub-60s token born-expired. The builder's clockSkewSeconds(int) option and field are removed; the per-instance clockSkewMillis becomes the CLOCK_SKEW_MILLIS constant, and a new tokenTtlMillis tracks the cached token's clamped lifetime. Removing the configurable skew also removes the lever several tests used to force a cached token to look expired (a short TTL was born-expired under the old flat 30s skew). Those tests now force expiry deterministically through a reflection helper (expireCachedToken) that zeroes expiresAtMillis without dropping the refresh token - no flaky sleeps. testClockSkewSecondsForcesEarlyRefresh is rewritten as testClockSkewCappedAtHalfTokenLifetime (proven to fail without the cap), and the expires_in clamp test now asserts the clamped expiry directly via reflection instead of abusing a large skew. This is an API change: the public clockSkewSeconds(...) builder method is removed. Nothing in the client itself calls it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 39 ++++---- .../test/cutlass/auth/OidcDeviceAuthTest.java | 95 +++++++++++++------ 2 files changed, 91 insertions(+), 43 deletions(-) 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 index bec2cbabe..2e0f7cad3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -98,7 +98,11 @@ 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"; - private static final int DEFAULT_CLOCK_SKEW_SECONDS = 30; + // 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; @@ -138,7 +142,6 @@ public class OidcDeviceAuth implements QuietCloseable { private static final String WELL_KNOWN_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration"; private final String audience; private final String clientId; - private final long clockSkewMillis; private final DeviceAuthorizationResponseParser deviceAuthParser = new DeviceAuthorizationResponseParser(); private final Endpoint deviceAuthorizationEndpoint; private final StringSink formSink = new StringSink(); @@ -161,6 +164,9 @@ public class OidcDeviceAuth implements QuietCloseable { private HttpClient plainClient; private String refreshToken; 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) { this.clientId = builder.clientId; @@ -170,7 +176,6 @@ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { this.audience = builder.audience; this.groupsInToken = builder.groupsInToken; this.httpTimeoutMillis = builder.httpTimeoutMillis; - this.clockSkewMillis = builder.clockSkewSeconds * 1000L; this.prompt = builder.prompt; this.tlsConfig = tlsConfig; // allocate the native lexer last: an Endpoint.parse above can throw on a malformed url, and @@ -358,6 +363,7 @@ public void clearCache() { idToken = null; refreshToken = null; expiresAtMillis = 0; + tokenTtlMillis = 0; } finally { lock.unlock(); } @@ -417,7 +423,7 @@ public String getToken() { // valid and have selectToken() throw on this and every later call final String cachedToken = groupsInToken ? idToken : accessToken; if (cachedToken != null) { - if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { + if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { return cachedToken; } if (refreshToken != null && tryRefresh()) { @@ -461,7 +467,7 @@ public String getTokenSilently() { throwIfClosed(); final String cachedToken = groupsInToken ? idToken : accessToken; if (cachedToken != null) { - if (System.currentTimeMillis() < expiresAtMillis - clockSkewMillis) { + if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { return cachedToken; } if (refreshToken != null && tryRefresh()) { @@ -886,6 +892,16 @@ 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) { @@ -1142,7 +1158,8 @@ private void storeTokens(TokenResponseParser parser) { // 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); - expiresAtMillis = System.currentTimeMillis() + ttlSeconds * 1000L; + tokenTtlMillis = ttlSeconds * 1000L; + expiresAtMillis = System.currentTimeMillis() + tokenTtlMillis; } private void throwIfClosed() { @@ -1202,7 +1219,6 @@ public static final class Builder { private boolean allowInsecureTransport; private String audience; private String clientId; - private int clockSkewSeconds = DEFAULT_CLOCK_SKEW_SECONDS; private String deviceAuthorizationEndpoint; private boolean groupsInToken; private int httpTimeoutMillis = DEFAULT_HTTP_TIMEOUT_MILLIS; @@ -1268,15 +1284,6 @@ public Builder clientId(String clientId) { return this; } - /** - * Seconds before the real expiry at which a cached token is treated as expired, absorbing clock - * drift and request latency. Defaults to 30. - */ - public Builder clockSkewSeconds(int clockSkewSeconds) { - this.clockSkewSeconds = clockSkewSeconds; - return this; - } - public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) { this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint; return this; 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 index 968b91621..a1a0044d8 100644 --- 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 @@ -41,6 +41,7 @@ import org.junit.Assume; import org.junit.Test; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.net.InetAddress; import java.net.ServerSocket; @@ -139,11 +140,11 @@ public void testAudienceSentOnRefresh() throws Exception { .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) .tokenEndpoint(server.httpUrl(TOKEN_PATH)) .audience("api://questdb") - .clockSkewSeconds(120) // larger than the 60s token lifetime, so getToken() refreshes .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { Assert.assertEquals("ACCESS-1", auth.getToken()); + expireCachedToken(auth); // force the silent-refresh path on the next call Assert.assertEquals("ACCESS-2", auth.getToken()); Assert.assertTrue(refreshBody.get(), refreshBody.get().contains("audience=api%3A%2F%2Fquestdb")); } @@ -424,10 +425,11 @@ public void testClearCacheForcesFreshSignIn() throws Exception { } @Test(timeout = 30_000) - public void testClockSkewSecondsForcesEarlyRefresh() throws Exception { + public void testClockSkewCappedAtHalfTokenLifetime() throws Exception { assertMemoryLeak(() -> { - // a clock skew larger than the token lifetime makes a freshly-issued token count as already - // expired, so the second getToken() refreshes instead of returning the cached token + // 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)) { @@ -437,19 +439,24 @@ public void testClockSkewSecondsForcesEarlyRefresh() throws Exception { refreshCalls.incrementAndGet(); return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600)); } - return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 60)); + 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)) - .clockSkewSeconds(120) // larger than the 60s token lifetime .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.getToken()); Assert.assertEquals("ACCESS-1", auth.getToken()); - // the 60s token sits within the 120s skew, so it is treated as expired and refreshed + Assert.assertEquals("the capped skew kept the short token cached - no refresh", 0, refreshCalls.get()); + + // once the token is genuinely past expiry, getToken() takes the silent-refresh path + expireCachedToken(auth); Assert.assertEquals("ACCESS-2", auth.getToken()); Assert.assertEquals(1, refreshCalls.get()); } @@ -1373,8 +1380,9 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { Assert.assertEquals("ACCESS-1", auth.getToken()); - // the cached token is expired vs the 30s skew, and the refresh body is garbled, so the - // client must re-run the interactive flow instead of throwing the parse error + 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.getToken()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); } @@ -1433,10 +1441,10 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Exception { assertMemoryLeak(() -> { // the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not - // just an interactive sign-in: getTokenSilently() must fail fast rather than queue behind it. A high - // clock skew keeps the cached token permanently "expired", so getTokenSilently() always refreshes; - // the token endpoint blocks the refresh response until the test releases it, pinning the lock on the - // refresher thread while the second caller races for it + // just an interactive sign-in: getTokenSilently() must fail fast rather than queue behind it. The + // cached token is forced expired so getTokenSilently() refreshes; the token endpoint blocks the + // refresh response until the test releases it, pinning the lock on the refresher thread while the + // second caller races for it CountDownLatch refreshInFlight = new CountDownLatch(1); CountDownLatch releaseRefresh = new CountDownLatch(1); MockOidcServer.Handler handler = (method, path, body) -> { @@ -1462,10 +1470,10 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) .tokenEndpoint(server.httpUrl(TOKEN_PATH)) .allowInsecureTransport(true) - .clockSkewSeconds(3600) // keep the cached token always "expired" so a refresh runs .prompt(noopPrompt()) .build()) { auth.getToken(); // sign in once: caches ACCESS-1 and a refresh token + expireCachedToken(auth); // so the refresher thread's getTokenSilently() takes the refresh path Thread refresher = new Thread(() -> { try { auth.getTokenSilently(); @@ -1527,10 +1535,12 @@ public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { } // sign in once interactively Assert.assertEquals("ACCESS-1", auth.getToken()); - // the cached token is expired vs the 30s skew, so getTokenSilently() refreshes silently + expireCachedToken(auth); + // the cached token is expired, so getTokenSilently() refreshes silently Assert.assertEquals("ACCESS-2", auth.getTokenSilently()); // now make the refresh fail; getTokenSilently() must throw, not start the device flow refreshOk.set(false); + expireCachedToken(auth); try { auth.getTokenSilently(); Assert.fail("expected getTokenSilently() to fail when the refresh is rejected"); @@ -2311,6 +2321,7 @@ public void testRefreshErrorFallsBackToInteractiveFlow() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { Assert.assertEquals("ACCESS-1", auth.getToken()); + expireCachedToken(auth); // the refresh is rejected, so the flow re-runs the interactive sign-in Assert.assertEquals("ACCESS-2", auth.getToken()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); @@ -2343,8 +2354,10 @@ public void testRefreshKeepsExistingRefreshTokenWhenOmitted() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { Assert.assertEquals("ACCESS-1", auth.getToken()); + expireCachedToken(auth); // first refresh omits refresh_token, so REFRESH-1 must be kept Assert.assertEquals("ACCESS-R1", auth.getToken()); + expireCachedToken(auth); // second refresh must still present the retained REFRESH-1 (asserted in the handler) Assert.assertEquals("ACCESS-R2", auth.getToken()); Assert.assertEquals("no extra interactive sign-in", 1, deviceCalls.get()); @@ -2378,8 +2391,9 @@ public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Ex try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { Assert.assertEquals("ACCESS-1", auth.getToken()); - // the cached token is expired vs the skew; the refresh carries an error+token, so the - // client must ignore the smuggled token and re-run the interactive flow + 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.getToken()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); } @@ -2412,6 +2426,7 @@ public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Excepti try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { Assert.assertEquals("ID-1", auth.getToken()); + 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.getToken()); @@ -2463,7 +2478,8 @@ public void testSilentRefreshWhenTokenExpired() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) { Assert.assertEquals("ACCESS-1", auth.getToken()); - // the cached token is expired vs the 30s skew, so the second call refreshes silently + expireCachedToken(auth); + // the cached token is expired, so the second call refreshes silently Assert.assertEquals("ACCESS-2", auth.getToken()); Assert.assertEquals("the interactive flow must run only once", 1, deviceCalls.get()); Assert.assertEquals("the user must be prompted only once", 1, promptCalls.get()); @@ -2634,11 +2650,9 @@ public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception @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 like - // the device-side value, so the client does not trust a stale cached token for decades. With the - // clock-skew margin set above the clamp, a clamped token reads as already-expired on the next - // call and getToken() re-runs the flow; an unclamped ~68-year cache would be served instead, so - // the device endpoint would be hit only once. + // 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)) { @@ -2656,14 +2670,24 @@ public void testTokenResponseExpiresInIsClamped() throws Exception { .scope("openid") .prompt(noopPrompt()) .allowInsecureTransport(true) - .clockSkewSeconds(7200) // 2h, above the 1h (MAX_EXPIRES_IN_SECONDS) clamp .build()) { + long before = System.currentTimeMillis(); Assert.assertEquals("ACCESS-OK", auth.getToken()); + long after = System.currentTimeMillis(); Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get()); - // the clamped 1h TTL minus the 2h skew is already in the past, so the next call re-runs the - // flow; without the clamp the ~68-year cache would be served and the flow would not run again + + // 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 getToken() re-runs the device flow + expireCachedToken(auth); Assert.assertEquals("ACCESS-OK", auth.getToken()); - Assert.assertEquals("clamped token expiry forces a fresh sign-in", 2, deviceCalls.get()); + Assert.assertEquals("expired clamped token forces a fresh sign-in", 2, deviceCalls.get()); } }); } @@ -2985,6 +3009,23 @@ private static OidcDeviceAuth.DiscoveryOptions insecure() { return new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true).prompt(noopPrompt()); } + // Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next + // getToken()/getTokenSilently() 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. + private 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 long readExpiresAtMillis(OidcDeviceAuth auth) throws Exception { + Field f = OidcDeviceAuth.class.getDeclaredField("expiresAtMillis"); + f.setAccessible(true); + return f.getLong(auth); + } + // 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 invokeIsLoopbackHost(String host) throws Exception { From aab512b3d42cbf4e3adf92df824085221e97bf59 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 00:15:50 +0100 Subject: [PATCH 039/192] Harden client response reads and display escaping Two hardening fixes from a review of the OIDC device-flow change. Bound the content-length HTTP response read. AbstractResponse.recv(int) re-armed the full timeout on every recvOrDie call, so a server returning repeated zero-byte reads - an incomplete or empty TLS record over a hostile or MITM'd link, where JavaTlsClientSocket.recv returns 0 on BUFFER_UNDERFLOW without a disconnect - kept a single recv() running without bound. That defeated the wall-clock deadline OidcDeviceAuth.parseBody relies on and could hang the flush or sign-in thread, and block close(), indefinitely. recv(int) now shares one deadline across reads, matching the chunked reader; a non-positive timeout keeps the legacy unbounded behaviour. Escape display-unsafe characters beyond the BMP. Utf16Sink.putAsPrintable scanned per UTF-16 unit, so a supplementary-plane format char (a U+E00xx tag char, which arrives as a surrogate pair) and a lone surrogate passed through raw - able to reorder, hide, or forge text in a terminal or log. It now scans whole code points and escapes control, format, and surrogate code points, matching OidcAuthException.isUnsafeForDisplay; a normal emoji still prints verbatim, and BMP output is unchanged. Both fixes add a regression test that fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/http/client/AbstractResponse.java | 17 ++++- .../io/questdb/client/std/str/Utf16Sink.java | 68 ++++++++++++++----- .../cutlass/http/client/ResponseTest.java | 31 +++++++++ .../cutlass/line/LineSenderExceptionTest.java | 31 +++++++++ 4 files changed, 129 insertions(+), 18 deletions(-) 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..b234d2a24 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 wall-clock 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/std/str/Utf16Sink.java b/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java index 4346ce9e9..3e790903a 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 @@ -45,29 +45,33 @@ 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 escapes them (matching OidcAuthException.isUnsafeForDisplay), while a normal supplementary + // char such as an emoji is neither control nor format and is emitted verbatim. + for (int i = 0, n = nonPrintable.length(); i < n; ) { + final int cp = Character.codePointAt(nonPrintable, i); + final int count = Character.charCount(cp); + if (isDisplaySafe(cp)) { + for (int j = 0; j < count; j++) { + put(nonPrintable.charAt(i + j)); + } + } else { + putUnicodeEscape(cp); + } + i += count; } } default void putAsPrintable(char c) { - // escape control chars (C0/C1, DEL) and Unicode format chars - bidi embeddings/overrides/isolates, - // LRM/RLM marks, zero-width joiners, the BOM - to a visible \\uXXXX. Left raw, attacker-influenced - // text (an ILP server's JSON error body, a column name) could reorder, hide or forge what a human - // reads in a terminal or log; escaping rather than stripping keeps it visible for diagnosis. Per - // UTF-16-unit scanning covers every BMP threat; a supplementary-plane char (emoji surrogate pair) is - // neither control nor format and passes through. Emitting all four hex digits keeps a format char - // above U+00FF (e.g. U+202E) correct rather than truncated to its low byte. - if (!Character.isISOControl(c) && Character.getType(c) != Character.FORMAT) { + // 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 (isDisplaySafe(c)) { put(c); } else { - put('\\'); - put('u'); - put(hexDigits[(c >> 12) & 0xF]); - put(hexDigits[(c >> 8) & 0xF]); - put(hexDigits[(c >> 4) & 0xF]); - put(hexDigits[c & 0xF]); + putUnicodeEscape(c); } } @@ -99,4 +103,34 @@ default Utf16Sink putNonAscii(long lo, long hi) { return this; } + // 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. + private void putUnicodeEscape(int cp) { + if (cp > 0xFFFF) { + putUnicodeEscape(Character.highSurrogate(cp)); + putUnicodeEscape(Character.lowSurrogate(cp)); + return; + } + put('\\'); + put('u'); + put(hexDigits[(cp >> 12) & 0xF]); + put(hexDigits[(cp >> 8) & 0xF]); + put(hexDigits[(cp >> 4) & 0xF]); + put(hexDigits[cp & 0xF]); + } + + // A code point is display-safe unless 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) or a surrogate (a lone half, with no displayable meaning). Left raw, attacker-influenced text - + // an ILP server's JSON error body, a column name - could reorder, hide or forge what a human reads in a + // terminal or log; escaping rather than stripping keeps it visible for diagnosis. + private static boolean isDisplaySafe(int cp) { + if (Character.isISOControl(cp)) { + return false; + } + final int type = Character.getType(cp); + return type != Character.FORMAT && type != Character.SURROGATE; + } + } \ No newline at end of file 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..9870c6454 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; @@ -48,6 +49,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/line/LineSenderExceptionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java index 4d05487e1..c6ddcb055 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,37 @@ 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_withErrNo() { LineSenderException e = new LineSenderException("message").errno(10); From 4430a5061c58b2402315494a2346de30baef9643 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 00:19:31 +0100 Subject: [PATCH 040/192] Fix HTTP client leak on lexer alloc failure fetchJson allocated the discovery HttpClient and then the JsonLexer outside the try, so when the lexer's native malloc threw (native OOM), control had not yet entered the try, the finally never ran, and the already-allocated client's native buffers leaked. Allocate the lexer inside the try, null-initialized, so the finally frees the client on that path too. The client-factory call stays outside the try, so its exception behaviour is unchanged and nothing leaks there (the lexer is not allocated yet). Mirrors the constructor's own "allocate the native lexer last" reasoning. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 2e0f7cad3..5df5855af 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -547,8 +547,12 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura HttpClient client = endpoint.isTls ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) : HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); - JsonLexer lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES); + // 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) From a654fbb5d740b571f965e2322e2b07438f2b8f7e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 00:30:03 +0100 Subject: [PATCH 041/192] Sort static helpers and pre-encode grant types Two cleanups in OidcDeviceAuth from the review, no behaviour change. Sort the private static helper methods alphabetically, per the member-ordering convention. Pure reorder - no content changed. Pre-encode the grant_type constants. pollOnce url-encoded the device-code grant type on every poll, and tryRefresh the refresh grant type on every refresh; both are constants, so encode them once at class load instead. The wire output is byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 156 +++++++++--------- 1 file changed, 80 insertions(+), 76 deletions(-) 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 index 5df5855af..49f280f56 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -111,6 +111,10 @@ public class OidcDeviceAuth implements QuietCloseable { private static final int DEFAULT_TOKEN_TTL_SECONDS = 300; private static final String ERROR_AUTHORIZATION_PENDING = "authorization_pending"; private static final String ERROR_SLOW_DOWN = "slow_down"; + // 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); private static final HttpClientConfiguration HTTP_CONFIG = DefaultHttpClientConfiguration.INSTANCE; // 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"; @@ -496,6 +500,21 @@ private static int boundedSeconds(int value, int defaultValue, int maxValue) { return Math.min(value, maxValue); } + 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); } @@ -543,6 +562,15 @@ private static void discoverSettings(Endpoint server, ClientTlsConfiguration tls "could not parse the QuestDB /settings response"); } + 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 void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError) { HttpClient client = endpoint.isTls ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) @@ -574,6 +602,19 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura } } + 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; + } + 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 @@ -601,43 +642,6 @@ private static boolean isDottedIpv4(String host) { return octets == 4 && digits > 0 && value <= 255; } - 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 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 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; - } - 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. @@ -672,43 +676,6 @@ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issu return true; } - private static String pathOnly(String url) { - // the path component only (drop any ?query / #fragment); a ;matrix parameter stays part of the path, - // so a traversal hidden in it (.../token;..%2f..) is still scanned - String path = Endpoint.parse(url).path; - for (int i = 0, n = path.length(); i < n; i++) { - char c = path.charAt(i); - if (c == '?' || c == '#') { - return path.substring(0, i); - } - } - return 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 boolean isLoopbackHost(String host) { // loopback traffic never leaves the host, so a plaintext /settings fetch to it has no network // interception risk; match localhost and the whole IPv4 127.0.0.0/8 block @@ -750,6 +717,43 @@ private static int parseIntOrZero(CharSequence value) { } } + private static String pathOnly(String url) { + // the path component only (drop any ?query / #fragment); a ;matrix parameter stays part of the path, + // so a traversal hidden in it (.../token;..%2f..) is still scanned + String path = Endpoint.parse(url).path; + for (int i = 0, n = path.length(); i < n; i++) { + char c = path.charAt(i); + if (c == '?' || c == '#') { + return path.substring(0, i); + } + } + return 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 @@ -977,7 +981,7 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS private int pollOnce(String deviceCode) { formSink.clear(); - formSink.putAscii("grant_type=").putAscii(urlEncode(GRANT_TYPE_DEVICE_CODE)); + formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_DEVICE_CODE_ENCODED); appendParam(formSink, "device_code", deviceCode); appendParam(formSink, "client_id", clientId); @@ -1174,7 +1178,7 @@ private void throwIfClosed() { private boolean tryRefresh() { formSink.clear(); - formSink.putAscii("grant_type=").putAscii(urlEncode(GRANT_TYPE_REFRESH_TOKEN)); + formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED); appendParam(formSink, "refresh_token", refreshToken); appendParam(formSink, "client_id", clientId); if (scope != null) { From 0865d0e3f2b392be8fb22783a0fe379ba4730de6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 02:08:07 +0100 Subject: [PATCH 042/192] Drop the OIDC connection on a bounded-read abort The token-poll loop reused a cached keep-alive connection after a bounded-read abort. parseBody throws HttpClientException on its wall-clock deadline or the 4 MiB response-body cap, leaving the response half-read with unconsumed bytes in the socket; readResponse drained the body only on a JsonException, so that HttpClientException escaped undrained and pollForToken swallowed it and kept polling the same dirty connection until the device code expired - defeating the response cap's own "cannot wedge the thread" guarantee. postForm now disconnects the client on any HttpClientException (the parseBody abort, a header-read timeout, or a send failure) before rethrowing, so the next poll or refresh reconnects with a clean socket. This mirrors the disconnect-on-failure handling in AbstractLineHttpSender.flush0. A second path reached the same dirty connection: a malformed body larger than 4 MiB throws JsonException (not the cap's HttpClientException), and discardBody bailed at the cap leaving unconsumed bytes, yet pollForToken treats that path as transient too. discardBody now reports whether it fully drained, and readResponse disconnects when it could not. The common case - a small garbled body that drains fully - keeps the connection, so the transient-retry behavior is unchanged. testPollAbortDropsDirtyConnectionAndReconnects stalls the first poll and succeeds on the second over a 10s device-code lifetime: it fails without the fix (getToken throws "device code expired" at ~10s) and passes with it (~2s). The full OidcDeviceAuthTest suite stays green (101 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 49 +++++++++++++------ .../test/cutlass/auth/OidcDeviceAuthTest.java | 37 ++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) 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 index 49f280f56..9c4c08066 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -519,28 +519,31 @@ private static ClientTlsConfiguration defaultTlsConfig() { return new ClientTlsConfiguration(null, null, ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL); } - private static void discardBody(Response body, int timeoutMillis) { + 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 + // 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; + return false; } Fragment fragment = body.recv((int) Math.max(1, Math.min(remainingNanos / 1_000_000L, Integer.MAX_VALUE))); if (fragment == null) { - return; + return true; } totalBytes += fragment.hi() - fragment.lo(); if (totalBytes > MAX_RESPONSE_BODY_BYTES) { - return; + return false; } } } catch (HttpClientException ignore) { - // the connection is re-established on the next request if it is now unusable + return false; } } @@ -1038,12 +1041,23 @@ private void postForm(Endpoint endpoint, JsonParser parser) { .header("User-Agent", USER_AGENT); request.withContent(); request.putAscii(formSink); - HttpClient.ResponseHeaders response = request.send(httpTimeoutMillis); - response.await(httpTimeoutMillis); - readResponse(response, parser); + 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 wall-clock 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; + } } - private void readResponse(HttpClient.ResponseHeaders response, JsonParser parser) { + 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(); @@ -1054,12 +1068,16 @@ private void readResponse(HttpClient.ResponseHeaders response, JsonParser parser // 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. + // 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') { - discardBody(body, httpTimeoutMillis); + if (!discardBody(body, httpTimeoutMillis)) { + client.disconnect(); + } throw new OidcAuthException("the identity provider returned a malformed HTTP status code"); } responseStatus.put(c); @@ -1070,8 +1088,11 @@ private void readResponse(HttpClient.ResponseHeaders response, JsonParser parser 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 - discardBody(body, httpTimeoutMillis); + // 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(']'); } 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 index a1a0044d8..10832e13e 100644 --- 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 @@ -2166,6 +2166,43 @@ public void testOversizedSettingsBodyAbortsAtSizeCap() throws Exception { }); } + @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 getToken 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.getToken()); + Assert.assertEquals(2, tokenCalls.get()); + } + }); + } + @Test(timeout = 30_000) public void testPollIntervalClampedTo60() throws Exception { assertMemoryLeak(() -> { From 7d52ad51e1ec8f31da7a8107e61b5f6251c8ec90 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 11:36:41 +0100 Subject: [PATCH 043/192] Harden OIDC URL parsing and address review nits Address the minor findings from the device-flow review, all in the OIDC client. Endpoint.parse now terminates the authority at the first '/', '?' or '#' (so a query or fragment on a path-less url is no longer folded into the host) and rejects userinfo (user@host), which the HTTP layer would otherwise try to connect to literally. isEndpointUnderIssuerPath rejects a percent-encoded path separator (%2f, %5c, or a double-encoded %25 form). decodePathSegments resolves it before the segment comparison, so it would split one segment in two and could let .../realms/acme%2fevil/token slip the issuer-path scope. A real OIDC endpoint path never encodes a separator. This is defense in depth: the origin and prefix checks already confine credentials to the pinned subtree. pollOnce handles a terminal OAuth error before the 429 rate-limit backoff, so a 429 that also carries access_denied (or another terminal error) aborts immediately instead of polling to the device-code deadline. runDeviceFlow sanitizes the user code and verification URL before the completeness check and requires them non-empty after sanitizing, and treats a verification_uri_complete that sanitizes to empty as absent, so an all-control field is not shown as a blank code/URL or handed to the browser launcher as "". Also: correct the class javadoc (the device-code lifetime caps the lock hold at 30 minutes, not an hour - an hour is the token-cache cap), rename the JsonLexer sawEscape flag to hasEscape, order Utf16Sink's static helper before its instance helper and restore the file's trailing newline, and document on Sender.httpTokenProvider that a sustained token outage terminates a WebSocket sender past its reconnect budget while the HTTP path retries on the next row. Adds four tests (encoded-slash rejection, 429-with-terminal-error fail-fast, and the two empty-after-sanitize cases) plus userinfo cases in testEndpointParseRejectsMalformedUrls; each fails without its fix. The full OidcDeviceAuthTest suite stays green (105 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 7 +- .../client/cutlass/auth/OidcDeviceAuth.java | 96 ++++++++++---- .../client/cutlass/json/JsonLexer.java | 18 +-- .../io/questdb/client/std/str/Utf16Sink.java | 29 +++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 117 ++++++++++++++++++ 5 files changed, 220 insertions(+), 47 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index c6dc22845..344151f4c 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2018,8 +2018,11 @@ public LineSenderBuilder httpToken(String token) { * The provider is not called at build time. Over HTTP the first call happens when the first row is * started, then once per flush. Over WebSocket the provider is queried once per connection handshake - * on the initial connect and again on every reconnect - so a refreshed token is presented each time the - * link is (re)established; an already-established WebSocket is not re-authenticated mid-stream. A - * lazily-signing-in provider can therefore be wired before the interactive sign-in completes, as long + * link is (re)established; an already-established WebSocket is not re-authenticated mid-stream. The two + * transports differ on a sustained token outage: over HTTP a failed pull is retried on the next row, + * but over WebSocket a pull that keeps failing past the reconnect budget terminates the sender for + * good, like any persistent reconnect failure. A lazily-signing-in provider can therefore be wired + * before the interactive sign-in completes, as long * as a token is obtainable before the first connect/row - otherwise that connect or row fails. Running * on the send/flush and reconnect paths, the provider must return promptly and must not block on * interactive input (see {@link HttpTokenProvider}). Supported over HTTP and WebSocket transport, and 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 index 9c4c08066..5d1b69686 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -82,7 +82,7 @@ * {@link #getToken()} 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 an hour), so a concurrent {@link #getToken()} or {@link #clearCache()} blocks + * lifetime (up to 30 minutes), so a concurrent {@link #getToken()} or {@link #clearCache()} blocks * behind it - but {@link #getTokenSilently()} never waits: it fails fast with an * {@link OidcAuthException} so a request/flush path never stalls. To abort a waiting sign-in, call * {@link #close()} from another thread; it signals the flow to stop, which then fails with an @@ -461,7 +461,7 @@ public String getToken() { public String getTokenSilently() { throwIfClosed(); // never wait on the flush path: getToken()'s sign-in holds the lock for the whole device-code - // lifetime (up to an hour), so tryLock and fail fast if held. A sign-in in progress means there + // lifetime (up to 30 minutes), so tryLock and fail fast if held. A sign-in in progress means there // is no token to serve yet, so the caller gets a prompt exception to retry rather than a stalled // flush if (!lock.tryLock()) { @@ -660,7 +660,21 @@ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issu return true; // root issuer: origin-only, every path is under it } String[] baseSegs = decodePathSegments(basePath.substring(0, baseEnd)); - String[] endpointSegs = decodePathSegments(pathOnly(endpointUrl)); + String rawEndpointPath = pathOnly(endpointUrl); + // reject a percent-encoded path separator - %2f ('/'), %5c ('\'), or a double-encoded form flagged by + // an encoded percent %25 (e.g. %252f). decodePathSegments resolves it before the segment comparison, + // so it would split one path segment in two and could let .../realms/acme%2fevil/token slip the + // issuer-path scope. A real OIDC endpoint path never encodes a separator. + for (int i = 0, n = rawEndpointPath.length() - 2; i < n; i++) { + if (rawEndpointPath.charAt(i) == '%') { + char a = rawEndpointPath.charAt(i + 1); + char b = rawEndpointPath.charAt(i + 2); + if ((a == '2' && (b == 'f' || b == 'F' || b == '5')) || (a == '5' && (b == 'c' || b == 'C'))) { + 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++) { @@ -993,15 +1007,10 @@ private int pollOnce(String deviceCode) { // the device-code deadline rather than swallowing it as a pending authorization postForm(tokenEndpoint, tokenParser); - // A rate-limited identity provider answers 429; 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.2: an error response is an error even if the body also carries a token, so handle the - // OAuth error first - a token smuggled alongside an error must never count as a grant + // 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; @@ -1011,6 +1020,14 @@ private int pollOnce(String deviceCode) { } 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()) { @@ -1122,18 +1139,32 @@ private void runDeviceFlow() { if (!isHttpStatusSuccess()) { throw new OidcAuthException().put("unexpected response from the device authorization endpoint [httpStatus=").put(responseStatus).put(']'); } - if (deviceAuthParser.deviceCode.length() == 0 || deviceAuthParser.userCode.length() == 0 - || deviceAuthParser.verificationUri.length() == 0) { + // 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 String deviceCode = deviceAuthParser.deviceCode.toString(); 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( - sanitizeForDisplay(deviceAuthParser.userCode.toString()), - sanitizeForDisplay(deviceAuthParser.verificationUri.toString()), - deviceAuthParser.verificationUriComplete.length() > 0 ? sanitizeForDisplay(deviceAuthParser.verificationUriComplete.toString()) : null, + userCode, + verificationUri, + verificationUriComplete, expiresInSeconds, intervalSeconds ); @@ -1601,9 +1632,32 @@ static Endpoint parse(String url) { throw new OidcAuthException().put("invalid url, expected http or https [url=").put(url).put(']'); } int hostStart = schemeEnd + 3; - int pathStart = url.indexOf('/', hostStart); - String hostPort = pathStart < 0 ? url.substring(hostStart) : url.substring(hostStart, pathStart); - String path = pathStart < 0 ? "/" : url.substring(pathStart); + // the authority ([userinfo@]host[:port]) ends at the first '/', '?' or '#'; splitting only on + // '/' (as before) folded a query/fragment - or userinfo - into the host on a path-less url + int authorityEnd = url.length(); + for (int i = hostStart, n = url.length(); i < n; i++) { + char c = url.charAt(i); + if (c == '/' || c == '?' || c == '#') { + authorityEnd = i; + break; + } + } + String hostPort = url.substring(hostStart, authorityEnd); + // a path-less url uses '/'; a query/fragment with no path is prefixed with '/' so the request + // line stays well-formed (a '/'-terminated authority already carries its own leading slash) + String path; + if (authorityEnd == url.length()) { + path = "/"; + } else if (url.charAt(authorityEnd) == '/') { + path = url.substring(authorityEnd); + } else { + path = "/" + 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 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 cd2e75c03..0ca209ba2 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 @@ -64,7 +64,7 @@ public class JsonLexer implements Mutable, Closeable { private int objDepth = 0; private int position = 0; private boolean quoted = false; - private boolean sawEscape = false; + private boolean hasEscape = false; private int state = S_START; private boolean useCache = false; @@ -87,7 +87,7 @@ public void clear() { arrayDepth = 0; ignoreNext = false; quoted = false; - sawEscape = false; + hasEscape = false; cacheSize = 0; useCache = false; position = 0; @@ -112,7 +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 sawEscape = this.sawEscape; + boolean hasEscape = this.hasEscape; boolean useCache = this.useCache; int objDepth = this.objDepth; int arrayDepth = this.arrayDepth; @@ -129,7 +129,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { if (quoted) { if (c == '\\') { ignoreNext = true; - sawEscape = true; + hasEscape = true; continue; } @@ -142,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, sawEscape), 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, sawEscape), vp); + listener.onEvent(arrayDepth > 0 ? EVT_ARRAY_VALUE : EVT_VALUE, getCharSequence(valueStart, p, vp, hasEscape), vp); state = S_EXPECT_COMMA; } @@ -245,7 +245,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { } valueStart = p; quoted = true; - sawEscape = false; + hasEscape = false; break; default: if (state != S_EXPECT_VALUE) { @@ -254,7 +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; - sawEscape = false; + hasEscape = false; break; } } @@ -264,7 +264,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { this.state = state; this.quoted = quoted; this.ignoreNext = ignoreNext; - this.sawEscape = sawEscape; + this.hasEscape = hasEscape; this.objDepth = objDepth; this.arrayDepth = arrayDepth; 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 3e790903a..e2cb39b09 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 @@ -103,6 +103,19 @@ default Utf16Sink putNonAscii(long lo, long hi) { return this; } + // A code point is display-safe unless 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) or a surrogate (a lone half, with no displayable meaning). Left raw, attacker-influenced text - + // an ILP server's JSON error body, a column name - could reorder, hide or forge what a human reads in a + // terminal or log; escaping rather than stripping keeps it visible for diagnosis. + private static boolean isDisplaySafe(int cp) { + if (Character.isISOControl(cp)) { + return false; + } + final int type = Character.getType(cp); + return type != Character.FORMAT && type != Character.SURROGATE; + } + // 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. @@ -119,18 +132,4 @@ private void putUnicodeEscape(int cp) { put(hexDigits[(cp >> 4) & 0xF]); put(hexDigits[cp & 0xF]); } - - // A code point is display-safe unless 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) or a surrogate (a lone half, with no displayable meaning). Left raw, attacker-influenced text - - // an ILP server's JSON error body, a column name - could reorder, hide or forge what a human reads in a - // terminal or log; escaping rather than stripping keeps it visible for diagnosis. - private static boolean isDisplaySafe(int cp) { - if (Character.isISOControl(cp)) { - return false; - } - final int type = Character.getType(cp); - return type != Character.FORMAT && type != Character.SURROGATE; - } - -} \ No newline at end of file +} 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 index 10832e13e..71dfda225 100644 --- 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 @@ -92,6 +92,68 @@ public void testAccessDeniedSurfacesOauthError() throws Exception { }); } + @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.getToken()); + 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())) { + try { + auth.getToken(); + Assert.fail("expected an all-control verification_uri to be rejected as incomplete"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete")); + } + } + }); + } + @Test(timeout = 30_000) public void testAudienceParameterSentToDeviceEndpoint() throws Exception { assertMemoryLeak(() -> { @@ -956,6 +1018,10 @@ public void testEndpointParseRejectsMalformedUrls() { 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"); @@ -1773,6 +1839,33 @@ public void testIssuerPathScopingAcceptsEndpointsUnderIssuerPath() throws Except }); } + @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(() -> { @@ -2229,6 +2322,30 @@ public void testPollIntervalClampedTo60() throws Exception { }); } + @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.getToken(); + 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(() -> { From 94da9998d2b8036c92b2f2445446da8b8e8f40ad Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 13:37:59 +0100 Subject: [PATCH 044/192] Reject control/non-ASCII chars in provider tokens The Sender's HTTP and WebSocket auth paths pulled an HttpTokenProvider token and checked only that it was non-blank before splicing it into an Authorization: Bearer header. A token carrying a CR/LF could inject into the request line, and a non-ASCII byte was silently truncated to one byte by the ASCII header writer, yielding a corrupt credential the server only answers with 401. OidcDeviceAuth already guards its own tokens this way, so a discovered OIDC token was safe, but a custom provider was not. Add HttpTokenProvider.validateToken(), which both transports now call: it rejects a null/empty/blank token and any character outside 0x20-0x7e, the same range OidcDeviceAuth.validateTokenChars enforces. The token is never placed in the exception message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 32 +++++++- .../main/java/io/questdb/client/Sender.java | 9 +- .../line/http/AbstractLineHttpSender.java | 11 ++- .../client/test/HttpTokenProviderTest.java | 82 +++++++++++++++++++ .../line/LineHttpSenderTokenProviderTest.java | 25 ++++-- 5 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index c58ee98a0..de221c682 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -24,6 +24,9 @@ package io.questdb.client; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.std.Chars; + /** * Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a * freshly refreshed token - e.g. {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender @@ -40,9 +43,36 @@ */ @FunctionalInterface public interface HttpTokenProvider { + /** + * Validates a token returned by {@link #getToken()} before the sender writes it into an + * {@code Authorization: Bearer} header. Rejects a null, empty or blank token, and any token + * carrying a control or non-ASCII character (outside {@code 0x20}-{@code 0x7e}): a real bearer + * token is printable ASCII, so a stray CR/LF (which would inject into the HTTP request line) or a + * non-ASCII byte (silently truncated to one byte by the ASCII header writer, yielding a corrupt + * credential the server only answers with 401) is refused rather than sent. The token itself is + * never placed in the exception message - it is the secret this guards. + * + * @param token the token returned by a provider + * @throws LineSenderException if the token is null, empty, blank, or carries a control or + * non-ASCII character + */ + static void validateToken(CharSequence token) { + if (Chars.isBlank(token)) { + throw new LineSenderException("token provider returned a null or empty token"); + } + for (int i = 0, n = token.length(); i < n; i++) { + char c = token.charAt(i); + if (c < 0x20 || c > 0x7e) { + throw new LineSenderException("token provider returned a token containing a control or non-ASCII character; refusing to send it as a credential"); + } + } + } + /** * Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the sender - * adds it). Must not return null or empty. + * adds it). Must not return null or empty, and must contain only printable ASCII (no control or + * non-ASCII characters) - the sender splices the value verbatim into an {@code Authorization: + * Bearer} header and rejects a token that violates this (see {@link #validateToken(CharSequence)}). * * @return the current HTTP authentication token */ diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 344151f4c..d0b0ccb9f 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2851,14 +2851,13 @@ private Supplier buildWebSocketAuthHeader() { } if (httpTokenProvider != null) { // pull a fresh token at each (re)handshake so a long-lived WebSocket follows token - // refreshes; reject a null/empty/blank return (forbidden by the HttpTokenProvider - // contract) rather than send a malformed "Bearer " header the server only 401s on + // refreshes; validateToken rejects a null/empty/blank return, or a token carrying a + // control or non-ASCII char (both forbidden by the HttpTokenProvider contract), rather + // than send a malformed or CR/LF-injected "Bearer " header final HttpTokenProvider provider = httpTokenProvider; return () -> { CharSequence token = provider.getToken(); - if (Chars.isBlank(token)) { - throw new LineSenderException("token provider returned a null or empty token"); - } + HttpTokenProvider.validateToken(token); return "Bearer " + token; }; } 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 82519f40d..ab41e73a2 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 @@ -763,13 +763,12 @@ private HttpClient.Request newRequest(boolean pullProviderToken) { r.authBasic(username, password); } else if (httpTokenProvider != null) { if (pullProviderToken) { - // pull a fresh token per request so a long-lived sender follows token refreshes; reject a - // null/empty/blank return (forbidden by the HttpTokenProvider contract) with a clear error - // rather than emit a malformed "Authorization: Bearer " header the server only 401s on + // pull a fresh token per request so a long-lived sender 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 splice a + // malformed or CR/LF-injected "Authorization: Bearer " header onto the wire CharSequence token = httpTokenProvider.getToken(); - if (Chars.isBlank(token)) { - throw new LineSenderException("token provider returned a null or empty token"); - } + HttpTokenProvider.validateToken(token); r.authToken(token); } else { // do NOT pull the token on the construct/flush path: getToken() can throw (not signed in 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/cutlass/line/LineHttpSenderTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java index 8a0725da0..66e0419fe 100644 --- 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 @@ -78,14 +78,27 @@ public void testBuildSucceedsWhenProviderHasNotSignedInYet() { } } + @Test + public void testControlOrNonAsciiProviderTokenIsRejected() { + // 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 public void testNullOrEmptyProviderTokenIsRejected() { // 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); - assertProviderTokenRejected(() -> ""); - assertProviderTokenRejected(() -> " "); + assertProviderTokenRejected(() -> null, "null or empty token"); + assertProviderTokenRejected(() -> "", "null or empty token"); + assertProviderTokenRejected(() -> " ", "null or empty token"); } @Test @@ -112,7 +125,7 @@ public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() { } } - private static void assertProviderTokenRejected(HttpTokenProvider provider) { + 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) @@ -121,9 +134,9 @@ private static void assertProviderTokenRejected(HttpTokenProvider provider) { .build()) { try { sender.table("t").longColumn("v", 1L).atNow(); - Assert.fail("expected a null or empty provider token to be rejected"); + Assert.fail("expected an invalid provider token to be rejected"); } catch (LineSenderException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("null or empty token")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); } } } From 15067f78ae72aae1f2c37f1b9d9199fb263b9833 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 13:51:41 +0100 Subject: [PATCH 045/192] Fix build-time pull claim in token provider docs The httpTokenProvider builder Javadoc claimed flatly "The provider is not called at build time." That holds over HTTP, where the first pull is deferred to the first row, but not over WebSocket: build() runs the initial connection handshake, which queries the provider once. A user wiring a lazily-signing-in provider (auth::getTokenSilently) over WebSocket before the interactive sign-in completes would hit a build() failure, contradicting the doc. Scope the build-time statement to HTTP and spell out that over WebSocket a token must already be obtainable when build() runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index d0b0ccb9f..2792b1d42 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2015,18 +2015,20 @@ public LineSenderBuilder httpToken(String token) { * 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::getTokenSilently)}. *
    - * The provider is not called at build time. Over HTTP the first call happens when the first row is - * started, then once per flush. Over WebSocket the provider is queried once per connection handshake - - * on the initial connect and again on every reconnect - so a refreshed token is presented each time the - * link is (re)established; an already-established WebSocket is not re-authenticated mid-stream. The two - * transports differ on a sustained token outage: over HTTP a failed pull is retried on the next row, - * but over WebSocket a pull that keeps failing past the reconnect budget terminates the sender for - * good, like any persistent reconnect failure. A lazily-signing-in provider can therefore be wired - * before the interactive sign-in completes, as long - * as a token is obtainable before the first connect/row - otherwise that connect or row fails. Running - * on the send/flush and reconnect paths, the provider must return promptly and must not block on - * interactive input (see {@link HttpTokenProvider}). Supported over HTTP and WebSocket transport, and - * mutually exclusive with {@link #httpToken(String)} and {@link #httpUsernamePassword(String, String)}. + * Over HTTP the provider is not called at build time: the first call happens when the first row is + * started, then once per flush. Over WebSocket the initial connection handshake runs during + * {@code build()} and queries the provider once for it, then again once per reconnect handshake - so a + * refreshed token is presented each time the link is (re)established; an already-established WebSocket + * is not re-authenticated mid-stream. The two transports differ on a sustained token outage: over HTTP + * a failed pull is retried on the next row, but over WebSocket a pull that keeps failing past the + * reconnect budget terminates the sender for good, like any persistent reconnect failure. A + * lazily-signing-in provider can therefore be wired before the interactive sign-in completes over HTTP, + * where the first pull is deferred to the first row; over WebSocket a token must already be obtainable + * when {@code build()} runs, since the initial handshake pulls it - otherwise that {@code build()} (or, + * over HTTP, the first row) fails. Running on the send/flush and reconnect paths, the provider must + * return promptly and must not block on interactive input (see {@link HttpTokenProvider}). Supported + * over HTTP and WebSocket transport, and mutually exclusive with {@link #httpToken(String)} and + * {@link #httpUsernamePassword(String, String)}. * * @param httpTokenProvider supplies the current HTTP authentication token * @return this instance for method chaining From 67c78d9faecf0bdb2f98dc281ced7a004a41afb0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 14:03:29 +0100 Subject: [PATCH 046/192] Harden issuer-path scope and fix review nits Follow-up to the OIDC device-flow review, four minor items: - isEndpointUnderIssuerPath now scans for an encoded path separator at every decode level, not just the raw string, and rejects a literal backslash. A split encoding such as %2%66 (which resolves to %2f then '/') and a backslash folded to '/' by decodePathSegments previously passed the single-pass pre-scan and could make a deeper endpoint masquerade as being under the pinned issuer path while a different raw path travelled on the wire. The origin pin already kept credentials on the trusted host, so this closes a defense-in-depth gap, not an exploit. - close()'s Javadoc no longer claims a hard one-HTTP-timeout bound: a DeviceCodePrompt that blocks in promptUser (the default browser launch) holds the lock while it runs, so a racing close() waits it out too. - testOutOfRangePollIntervalAndExpiryAreClamped now asserts the exact clamped values (60s interval, 1800s device-code lifetime) instead of bounds 5x and 2x looser than the real maxima. - Move the JsonLexer hasEscape field to its alphabetical position. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 60 ++++++++++++++----- .../client/cutlass/json/JsonLexer.java | 2 +- .../test/cutlass/auth/OidcDeviceAuthTest.java | 31 +++++++++- 3 files changed, 74 insertions(+), 19 deletions(-) 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 index 5d1b69686..69ad9a31d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -89,7 +89,9 @@ * {@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. + * {@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 and does not survive a process restart. @@ -380,8 +382,12 @@ public void clearCache() { * 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, i.e. after at most one HTTP request timeout - * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. Idempotent. After - * close, {@link #getToken()} and {@link #clearCache()} throw. + * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. 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 #getToken()} and + * {@link #clearCache()} throw. */ @Override public void close() { @@ -574,6 +580,37 @@ private static OidcAuthException endpointNotUnderIssuer(String label, String url .put("explicitly with OidcDeviceAuth.builder()"); } + private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) { + // Scan for a literal backslash (decodePathSegments folds it to '/') or a percent-encoded path + // separator - %2f ('/'), %5c ('\'), or an encoded percent %25 that gates a split or double encoding + // such as %2%66 or %252f - at every decode level, not just the raw string. A separator that only + // emerges after the server unescapes more than once would pass a single-pass scan yet split one + // segment in two, letting .../realms/acme%2%66evil/token slip the issuer-path scope. A real OIDC + // endpoint path encodes none of these. Bounded like decodePathSegments; a real path needs 0-1 passes. + String decoded = rawEndpointPath; + for (int pass = 0; pass < 10; pass++) { + for (int i = 0, n = decoded.length(); i < n; i++) { + char c = decoded.charAt(i); + if (c == '\\') { + return true; + } + if (c == '%' && i + 2 < n) { + char a = decoded.charAt(i + 1); + char b = decoded.charAt(i + 2); + if ((a == '2' && (b == 'f' || b == 'F' || b == '5')) || (a == '5' && (b == 'c' || b == 'C'))) { + return true; + } + } + } + String next = percentDecodeOnce(decoded); + if (next.equals(decoded)) { + break; + } + decoded = next; + } + return false; + } + private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError) { HttpClient client = endpoint.isTls ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) @@ -661,18 +698,11 @@ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issu } String[] baseSegs = decodePathSegments(basePath.substring(0, baseEnd)); String rawEndpointPath = pathOnly(endpointUrl); - // reject a percent-encoded path separator - %2f ('/'), %5c ('\'), or a double-encoded form flagged by - // an encoded percent %25 (e.g. %252f). decodePathSegments resolves it before the segment comparison, - // so it would split one path segment in two and could let .../realms/acme%2fevil/token slip the - // issuer-path scope. A real OIDC endpoint path never encodes a separator. - for (int i = 0, n = rawEndpointPath.length() - 2; i < n; i++) { - if (rawEndpointPath.charAt(i) == '%') { - char a = rawEndpointPath.charAt(i + 1); - char b = rawEndpointPath.charAt(i + 2); - if ((a == '2' && (b == 'f' || b == 'F' || b == '5')) || (a == '5' && (b == 'c' || b == 'C'))) { - return false; - } - } + // 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 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 0ca209ba2..0c4b0c4cd 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 @@ -60,11 +60,11 @@ public class JsonLexer implements Mutable, Closeable { 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; private boolean quoted = false; - private boolean hasEscape = false; private int state = S_START; private boolean useCache = false; 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 index 71dfda225..d8d3863f8 100644 --- 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 @@ -1918,6 +1918,21 @@ public void testIssuerPathScopingRejectsSiblingRealm() throws Exception { }); } + @Test(timeout = 30_000) + public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() throws Exception { + // hardening: an encoded path separator can hide behind a SPLIT encoding (%2%66 -> %2f -> '/') or a + // double encoding (%252f), and a literal backslash is folded to '/' by decodePathSegments. Each lets an + // extra segment masquerade as being under the issuer path while a different raw path travels on the + // wire, so isEndpointUnderIssuerPath must reject them. Only the path is scoped; the origin matches here. + String issuer = "https://idp.example.com/realms/acme"; + // a genuine sub-path endpoint stays accepted + Assert.assertTrue(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/protocol/token", issuer)); + // split, double, and literal-backslash separators all resolve to a deeper /realms/acme/evil and are rejected + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%2%66evil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%252fevil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme\\evil/token", issuer)); + } + @Test(timeout = 30_000) public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exception { assertMemoryLeak(() -> { @@ -2230,9 +2245,10 @@ public void testOutOfRangePollIntervalAndExpiryAreClamped() throws Exception { Assert.assertEquals("ACCESS-CLAMP", auth.getToken()); DeviceAuthorizationChallenge challenge = shown.get(); Assert.assertNotNull(challenge); - // the absurd interval/expires_in are clamped to the documented maxima - Assert.assertTrue("interval=" + challenge.getIntervalSeconds(), challenge.getIntervalSeconds() <= 300); - Assert.assertTrue("expiresIn=" + challenge.getExpiresInSeconds(), challenge.getExpiresInSeconds() <= 3600); + // 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()); } }); } @@ -3180,6 +3196,15 @@ private static long readExpiresAtMillis(OidcDeviceAuth auth) throws Exception { 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 + private static boolean invokeIsEndpointUnderIssuerPath(String endpointUrl, String issuer) throws Exception { + Method m = OidcDeviceAuth.class.getDeclaredMethod("isEndpointUnderIssuerPath", String.class, String.class); + m.setAccessible(true); + return (boolean) m.invoke(null, endpointUrl, issuer); + } + // 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 invokeIsLoopbackHost(String host) throws Exception { From d5bcf9336fc1ec8aff041daeb1e2caecf3a91a9f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 15:06:26 +0100 Subject: [PATCH 047/192] Escape control/bidi chars in flush error messages throwOnHttpErrorResponse echoed the raw HTTP error body into the LineSenderException message on three paths - the 401/403 auth body, a non-JSON body, and the toException JSON-parse-failure fallback - while only the parsed-JSON path went through putAsPrintable. A hostile, MITM'd or proxied endpoint could splice ANSI escapes, CR/LF or bidi overrides into a logged or printed exception (terminal hijack, log forging, visual spoofing). Route all three paths through LineSenderException.putAsPrintable so the server body is escaped just like the parsed-JSON fields already are. Add LineHttpSenderErrorResponseTest cases for the auth, non-JSON and malformed-JSON paths, each asserting a smuggled ESC or bidi override surfaces escaped, never raw. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../line/http/AbstractLineHttpSender.java | 17 ++- .../line/LineHttpSenderErrorResponseTest.java | 127 +++++++++++++++++- 2 files changed, 132 insertions(+), 12 deletions(-) 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 ab41e73a2..835fa209d 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 @@ -837,7 +837,9 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. chunkedResponseToSink(response, sink); 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(']'); client.disconnect(); @@ -855,11 +857,14 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. } // 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(']'); + // 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=").put(statusCode.asAsciiCharSequence()).put(']'); client.disconnect(); - throw new LineSenderException(sink, retryable); + throw ex; } private void validateNotClosed() { @@ -1067,7 +1072,9 @@ LineSenderException toException(Response chunkedRsp, DirectUtf8Sequence httpStat while ((fragment = chunkedRsp.recv()) != 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=").put(httpStatus.asAsciiCharSequence()).put(']'); reset(); return exception; } 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 index c3256cff0..4cdf665c0 100644 --- 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 @@ -33,15 +33,56 @@ import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; /** - * Verifies that the JSON error body a QuestDB HTTP endpoint returns on a failed flush is rendered - * safely into the {@link LineSenderException} message. The JSON lexer resolves string escapes, so a - * {@code message} or {@code errorId} field arrives fully decoded; a hostile or proxied endpoint could - * otherwise smuggle real control characters or ANSI escapes 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. + * 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 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 testServerJsonErrorBidiAndZeroWidthAreEscaped() throws Exception { assertMemoryLeak(() -> { @@ -117,11 +158,83 @@ public void testServerJsonErrorControlCharsAreEscaped() throws Exception { // ...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('\u001b') >= 0); + 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); + } + } + } + }); + } } From 23b6656bdd723420a7724eec89649218878f491f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 24 Jun 2026 19:23:21 +0100 Subject: [PATCH 048/192] Unify display-safety classifier and add tests Replace the two divergent display-safety predicates (Utf16Sink.isDisplaySafe and OidcAuthException.isUnsafeForDisplay) with one shared DisplaySafe classifier so both judge identically. DisplaySafe adds a printable-ASCII fast path that skips the Character.getType table lookup for the common case, and keeps the explicit bidi/BOM set as defense on a non-conformant JDK. Fill review-flagged test gaps: - ResponseTest/ChunkedResponseTest: the no-arg recv() bounded branch under a positive default timeout (the ILP flush read path). - OidcDeviceAuthTest: a non-ASCII (> 0x7e) token rejected, and a token response with expires_in <= 0 falling back to the default TTL. - BrowserLauncherTest: assert the no-op is the intended path (URL rejection or the kill-switch), not an incidental headless no-op. - LineHttpSenderTokenProviderTest and WebSocketTokenProviderTest now run under assertMemoryLeak, proving the senders free native buffers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/auth/OidcAuthException.java | 25 +-- .../questdb/client/std/str/DisplaySafe.java | 74 ++++++++ .../io/questdb/client/std/str/Utf16Sink.java | 21 +-- .../cutlass/auth/BrowserLauncherTest.java | 18 +- .../test/cutlass/auth/OidcDeviceAuthTest.java | 61 +++++++ .../http/client/ChunkedResponseTest.java | 32 ++++ .../cutlass/http/client/ResponseTest.java | 28 +++ .../line/LineHttpSenderTokenProviderTest.java | 143 ++++++++------- .../client/WebSocketTokenProviderTest.java | 166 ++++++++++-------- 9 files changed, 386 insertions(+), 182 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/std/str/DisplaySafe.java 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 index 1ccd6dbee..92d0f1df6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -24,6 +24,7 @@ package io.questdb.client.cutlass.auth; +import io.questdb.client.std.str.DisplaySafe; import io.questdb.client.std.str.StringSink; /** @@ -66,24 +67,14 @@ public static OidcAuthException oauthError(CharSequence error, CharSequence desc return e; } - // Reports characters that must never reach a terminal or log line. 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, having no displayable meaning. - // Beyond the C0/C1 controls and DEL from isISOControl, this strips the Unicode format category (Cf: - // zero-width joiners, BOM, bidi embedding/override/isolate controls, U+E00xx tag chars) plus an - // explicit bidi/BOM set, so an attacker-influenced value (verification_uri, user_code, error string) - // cannot reorder, hide, or spoof displayed text - even on a JDK that categorizes these differently. - // Hex literals (not char escapes) keep this source ASCII, so it carries none of the chars it guards. + // 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 Character.isISOControl(c) - || Character.getType(c) == Character.FORMAT - || Character.getType(c) == Character.SURROGATE // unpaired surrogate (lone half), no displayable meaning - || (c >= 0x202A && c <= 0x202E) // LRE, RLE, PDF, LRO, RLO - || (c >= 0x2066 && c <= 0x2069) // LRI, RLI, FSI, PDI - || c == 0x200E || c == 0x200F // LRM, RLM - || c == 0xFEFF; // BOM / zero-width no-break space + return DisplaySafe.isUnsafeForDisplay(c); } @Override 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..71895f4ed --- /dev/null +++ b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java @@ -0,0 +1,74 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/** + * 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) 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); + if (type == Character.FORMAT || type == Character.SURROGATE) { + 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); + } +} 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 e2cb39b09..a706ee494 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 @@ -48,12 +48,12 @@ default void putAsPrintable(CharSequence nonPrintable) { // 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 escapes them (matching OidcAuthException.isUnsafeForDisplay), while a normal supplementary - // char such as an emoji is neither control nor format and is emitted verbatim. + // 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. for (int i = 0, n = nonPrintable.length(); i < n; ) { final int cp = Character.codePointAt(nonPrintable, i); final int count = Character.charCount(cp); - if (isDisplaySafe(cp)) { + if (DisplaySafe.isDisplaySafe(cp)) { for (int j = 0; j < count; j++) { put(nonPrintable.charAt(i + j)); } @@ -68,7 +68,7 @@ default void putAsPrintable(char c) { // 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 (isDisplaySafe(c)) { + if (DisplaySafe.isDisplaySafe(c)) { put(c); } else { putUnicodeEscape(c); @@ -103,19 +103,6 @@ default Utf16Sink putNonAscii(long lo, long hi) { return this; } - // A code point is display-safe unless 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) or a surrogate (a lone half, with no displayable meaning). Left raw, attacker-influenced text - - // an ILP server's JSON error body, a column name - could reorder, hide or forge what a human reads in a - // terminal or log; escaping rather than stripping keeps it visible for diagnosis. - private static boolean isDisplaySafe(int cp) { - if (Character.isISOControl(cp)) { - return false; - } - final int type = Character.getType(cp); - return type != Character.FORMAT && type != Character.SURROGATE; - } - // 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. 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 index f2c82e65b..21d80da96 100644 --- 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 @@ -42,8 +42,12 @@ public void testAcceptsHttpAndHttps() throws Exception { @Test public void testOpenIsBestEffortForRejectedUrls() throws Exception { - // a rejected or absent URL returns before touching java.awt.Desktop, so open() must not throw - // (and the test never launches a real browser, so it is safe on a desktop machine too) + // 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"); @@ -51,13 +55,17 @@ public void testOpenIsBestEffortForRejectedUrls() throws Exception { @Test public void testOpenRespectsDisableProperty() throws Exception { - // with the kill-switch off, open() returns before touching the desktop even for a valid http(s) - // URL; this is also what keeps the suite from launching a real browser on a developer machine + // a VALID http(s) URL: if open() did not short-circuit on the kill-switch it would proceed toward + // java.awt.Desktop, so asserting safeHttpUri accepts it proves the no-op below is the kill-switch, + // not URL rejection. This gate is also what keeps the suite from launching a real browser on a + // developer machine. + 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); System.setProperty(prop, "false"); try { - invokeOpen("https://idp.example.com/device?user_code=ABCD"); + invokeOpen(validUrl); // kill-switch off: must return without launching and without throwing } finally { if (prev == null) { System.clearProperty(prop); 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 index d8d3863f8..c12ec6a2f 100644 --- 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 @@ -2862,6 +2862,39 @@ public void testTokenResponseExpiresInIsClamped() throws Exception { }); } + @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.getToken()); + 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(() -> { @@ -2915,6 +2948,34 @@ public void testTokenWithControlCharsRejected() throws Exception { }); } + @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())) { + try { + auth.getToken(); + Assert.fail("expected a token with a non-ASCII character to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); + // 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(() -> { 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 98b9f7af1..2a80a9584 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 @@ -187,6 +187,38 @@ 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 testRecvHonoursTotalTimeoutWhileChunkSizeDribbles() { // a server that dribbles the chunk-size line and never sends its terminating CRLF must not keep a 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 9870c6454..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 @@ -38,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 = { 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 index 66e0419fe..de05bd260 100644 --- 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 @@ -33,6 +33,8 @@ 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 @@ -42,87 +44,96 @@ *

    * An explicit {@code protocol_version} keeps {@link Sender.LineSenderBuilder#build()} from probing * the server, and auto-flush is disabled, so rows can be buffered against a port nobody listens on - * without ever opening a connection. + * without ever opening a connection. Each test runs under {@code assertMemoryLeak} so the sender's + * native buffers are proven freed on close. */ public class LineHttpSenderTokenProviderTest { @Test - public void testBuildSucceedsWhenProviderHasNotSignedInYet() { - // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getTokenSilently - 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 { + public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception { + assertMemoryLeak(() -> { + // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getTokenSilently + 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.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")); + Assert.assertTrue("row must be buffered after signing in", sender.bufferView().size() > 0); } - // 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 - public void testControlOrNonAsciiProviderTokenIsRejected() { - // 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 + 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 - public void testNullOrEmptyProviderTokenIsRejected() { - // 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"); + 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() { - 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()); - } + 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()); + } + }); } private static void assertProviderTokenRejected(HttpTokenProvider provider, String expectedMessage) { 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 index 8e81076ef..cb8b8ef4e 100644 --- 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 @@ -38,6 +38,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +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 @@ -46,104 +48,114 @@ * 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 public void testProviderRequeriedOnEveryReconnect() throws Exception { - // 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); + 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 { - 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()); + 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 { - // 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(); + 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 testUsernamePasswordStillSuppliedOverWebSocket() throws Exception { - // 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(); + 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 From cea40a5cbfe73cd316c796ac8041292873aac505 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 02:38:11 +0100 Subject: [PATCH 049/192] Trust discovered OIDC endpoints; drop discoveryUrl Scope the issuer-origin pin to only the endpoints the untrusted /settings response advertised. Endpoints discovered from the issuer's own .well-known are fetched out-of-band from the pinned origin and are authoritative for wherever the issuer hosts them, so they are no longer forced onto the issuer's origin. This lets an identity provider that serves its endpoints off the issuer origin - Google, Azure AD - sign in through discovery with an issuer pin. The co-location check (token and device share one origin) still applies to every endpoint, and a /settings-advertised endpoint is still origin- and path-pinned. Remove the DiscoveryOptions.discoveryUrl pin: an issuer already drives .well-known discovery, so it was redundant. With it go its self-issuer check, its origin-derivation, the discoverFromIdp parameter, and the now-unused issuer field in the discovery-document parser. Add a test for the off-origin discovered-endpoint case and drop the four discoveryUrl tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 146 ++++++++--------- .../test/cutlass/auth/OidcDeviceAuthTest.java | 148 +++++------------- 2 files changed, 106 insertions(+), 188 deletions(-) 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 index 69ad9a31d..9f5285033 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -217,8 +217,8 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl) { /** * Discovers the OIDC configuration from a running QuestDB server, like {@link #fromQuestDB(String)}, - * but with explicit {@link DiscoveryOptions}: an identity provider pin (issuer or discovery URL), a - * TLS configuration, an insecure-transport opt-in, and the device code prompt - for example + * 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} @@ -226,11 +226,10 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl) { * 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 or discovery URL was pinned + * authorization endpoint and no issuer was pinned */ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions options) { String issuer = options.issuer; - String discoveryUrl = options.discoveryUrl; ClientTlsConfiguration tlsConfig = options.tlsConfig != null ? options.tlsConfig : defaultTlsConfig(); boolean allowInsecureTransport = options.allowInsecureTransport; Endpoint server = Endpoint.parse(questdbUrl); @@ -248,7 +247,11 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt 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; - String pinnedDiscoveryUrl = discoveryUrl != null && !discoveryUrl.isEmpty() ? discoveryUrl : 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 @@ -257,7 +260,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt // 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 && pinnedDiscoveryUrl == null && settingsChannelIsPlaintext(server)) { + 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 ") @@ -287,7 +290,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt // 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 && pinnedDiscoveryUrl == 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 ") @@ -297,40 +300,30 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt .put(questdbUrl).put(']'); } WellKnownDiscoveryParser doc = new WellKnownDiscoveryParser(); - discoverFromIdp(resolvedIssuer, pinnedDiscoveryUrl, tlsConfig, allowInsecureTransport, doc); + 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(); } - // The endpoint pin's trust anchor is the out-of-band discovery origin (the caller's issuer, else - // the discoveryUrl origin derived after this block), never an issuer the document declares about - // itself. When discovery ran off a pinned discoveryUrl (no caller issuer), reject a document - // whose own "issuer" is on a different origin (RFC 8414 section 3.3): else a tampered or - // content-injected document at the pinned url could name an attacker issuer, co-locate both - // endpoints under it, and route the device code and refresh token there while the checks below - // pass trivially. A provider serving its discovery document on a different origin than its - // endpoints must use explicit endpoints via OidcDeviceAuth.builder(). - if (resolvedIssuer == null && pinnedDiscoveryUrl != null && doc.issuer.length() > 0) { - Endpoint docIssuer = Endpoint.parse(doc.issuer.toString()); - Endpoint discoveryEndpoint = Endpoint.parse(pinnedDiscoveryUrl); - if (!sameOrigin(docIssuer, discoveryEndpoint)) { - throw new OidcAuthException() - .put("the OIDC discovery document declares an issuer (").put(originOf(docIssuer)) - .put(") on a different origin than the pinned discovery url (").put(originOf(discoveryEndpoint)) - .put("); refusing to send credentials to an issuer outside the pinned discovery origin"); - } - } } - // A caller-supplied discoveryUrl pins the provider just as an issuer does: derive the pin origin - // from it so validateEndpointOrigins rejects any endpoint not on it - whether read from the - // discovery document above or advertised by /settings (when it supplied both endpoints and the - // discovery branch was skipped). Without this, a tampered response advertising both endpoints at one - // attacker origin would slip past a discoveryUrl pin, the co-location check alone passing trivially. - if (resolvedIssuer == null && pinnedDiscoveryUrl != null) { - resolvedIssuer = originOf(Endpoint.parse(pinnedDiscoveryUrl)); + // 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 && !sameOrigin(Endpoint.parse(tokenEndpoint), pin)) { + throw endpointOriginNotPinned("token endpoint", tokenEndpoint, originOf(pin)); + } + if (deviceEndpointFromSettings && !sameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) { + throw endpointOriginNotPinned("device authorization endpoint", deviceAuthorizationEndpoint, originOf(pin)); + } } if (tokenEndpoint == null) { @@ -351,7 +344,6 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt .scope(parser.scope.length() > 0 ? parser.scope.toString() : DEFAULT_SCOPE) .audience(parser.audience.length() > 0 ? parser.audience.toString() : null) .groupsInToken(parser.groupsInToken) - .issuer(resolvedIssuer) .allowInsecureTransport(allowInsecureTransport) .tlsConfig(tlsConfig) .prompt(options.prompt) @@ -553,13 +545,12 @@ private static boolean discardBody(Response body, int timeoutMillis) { } } - private static void discoverFromIdp(String issuer, String discoveryUrl, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport, WellKnownDiscoveryParser parser) { - // the discovery URL is pinned out of band (a caller-supplied discoveryUrl, else built from the - // issuer; the caller guarantees one is non-null), so the server cannot choose where discovery - - // and the credential POSTs it resolves - are aimed - String url = discoveryUrl != null ? discoveryUrl : wellKnownUrl(issuer); + 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 / discovery url", url, allowInsecureTransport); + 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"); @@ -580,6 +571,15 @@ private static OidcAuthException endpointNotUnderIssuer(String label, String url .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) { // Scan for a literal backslash (decodePathSegments folds it to '/') or a percent-encoded path // separator - %2f ('/'), %5c ('\'), or an encoded percent %25 that gates a split or double encoding @@ -891,10 +891,12 @@ private static String urlEncode(String value) { 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), and - when - // the issuer is pinned - reject either endpoint not on it. The pin compares origins, so a provider - // hosting its endpoints on a different origin than its issuer must be configured without an issuer - // (or with explicit endpoints). + // 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 (!sameOrigin(tokenEndpoint, deviceAuthorizationEndpoint)) { throw new OidcAuthException() .put("the OIDC token and device authorization endpoints are on different origins (") @@ -1396,14 +1398,16 @@ public Builder httpTimeoutMillis(int httpTimeoutMillis) { /** * Pins the identity provider by its {@code issuer} origin (for example - * {@code https://idp.example.com}). When set, {@link #build()} rejects a token or device - * authorization endpoint not on this origin, so a compromised or tampered configuration cannot - * redirect the device code and refresh token to an attacker. {@link #fromQuestDB(String, DiscoveryOptions)} - * sets it for you when discovering from a server, and additionally requires each endpoint advertised - * by {@code /settings} to be under the issuer's path (not just its origin), so a tampered - * {@code /settings} cannot redirect credentials to a different tenant on a path-based provider (for - * example a Keycloak realm path like {@code /realms/acme}). A provider hosting its endpoints on a - * different origin than its issuer is rejected when pinned; configure it without an issuer. Optional. + * {@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; @@ -1439,13 +1443,12 @@ public Builder tokenEndpoint(String tokenEndpoint) { /** * Options for {@link #fromQuestDB(String, DiscoveryOptions)}: how to pin the identity provider - * (issuer or discovery URL), 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)}. + * (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 discoveryUrl; private String issuer; private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser(); private ClientTlsConfiguration tlsConfig; @@ -1461,28 +1464,18 @@ public DiscoveryOptions allowInsecureTransport(boolean allowInsecureTransport) { return this; } - /** - * Pins the identity provider by its discovery document URL directly, an alternative to - * {@link #issuer(String)} (which otherwise derives {@code {issuer}/.well-known/openid-configuration}). - * Either pins where discovery - and the credential requests it resolves - are aimed, so a tampered - * {@code /settings} cannot redirect them. Optional. - */ - public DiscoveryOptions discoveryUrl(String discoveryUrl) { - this.discoveryUrl = discoveryUrl; - 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 pins the token and device authorization endpoints, - * so any endpoint not on the issuer origin is rejected. When the issuer has a path, an endpoint - * advertised by {@code /settings} must also be under that path, so a tampered {@code /settings} - * cannot redirect credentials to a different tenant on a path-based provider (for example a Keycloak - * realm path like {@code /realms/acme}). A provider hosting its endpoints on a different origin than - * its issuer must be configured without an issuer. Optional. + * 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; @@ -1910,11 +1903,9 @@ public void onEvent(int code, CharSequence tag, int position) { private static final class WellKnownDiscoveryParser implements JsonParser { private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 1; - private static final int FIELD_ISSUER = 3; private static final int FIELD_NONE = 0; private static final int FIELD_TOKEN_ENDPOINT = 2; final StringSink deviceAuthorizationEndpoint = new StringSink(); - final StringSink issuer = new StringSink(); final StringSink tokenEndpoint = new StringSink(); private int depth; private int field = FIELD_NONE; @@ -1936,8 +1927,6 @@ public void onEvent(int code, CharSequence tag, int position) { field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT; } else if (Chars.equals("token_endpoint", tag)) { field = FIELD_TOKEN_ENDPOINT; - } else if (Chars.equals("issuer", tag)) { - field = FIELD_ISSUER; } else { field = FIELD_NONE; } @@ -1952,9 +1941,6 @@ public void onEvent(int code, CharSequence tag, int position) { case FIELD_TOKEN_ENDPOINT: putNonNull(tokenEndpoint, tag); break; - case FIELD_ISSUER: - putNonNull(issuer, tag); - break; default: break; } 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 index c12ec6a2f..37cda13cf 100644 --- 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 @@ -1156,34 +1156,6 @@ public void testFromQuestDbDiscoversDeviceEndpointFromIssuer() throws Exception }); } - @Test(timeout = 30_000) - public void testFromQuestDbDiscoversFromDiscoveryUrl() throws Exception { - assertMemoryLeak(() -> { - // a discovery url pins the identity provider directly (an alternative to an issuer); the device - // endpoint and the issuer to pin against both come from the discovery document - 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-DU", "ID-DU", null, 3600)); - }; - try (MockOidcServer server = new MockOidcServer(handler)) { - serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)))) { - Assert.assertEquals("ID-DU", auth.getToken()); - } - } - }); - } - @Test(timeout = 30_000) public void testFromQuestDbDiscoveryDocMissingDeviceEndpointRejected() throws Exception { assertMemoryLeak(() -> { @@ -1237,89 +1209,49 @@ public void testFromQuestDbDiscoveryRunsFlow() throws Exception { } @Test(timeout = 30_000) - public void testFromQuestDbDiscoveryUrlPinAcceptsOnOriginAdvertisedEndpoints() throws Exception { + public void testFromQuestDbIssuerPinAcceptsOffOriginDiscoveredEndpoints() throws Exception { assertMemoryLeak(() -> { - // /settings advertises both endpoints on the same origin as the pinned discoveryUrl, so the pin - // is satisfied and the flow completes - and without a discovery round-trip, since the discovery - // branch is skipped when both endpoints are already advertised - AtomicReference serverRef = new AtomicReference<>(); - AtomicBoolean wellKnownHit = new AtomicBoolean(false); - 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 (WELL_KNOWN_PATH.equals(path)) { - wellKnownHit.set(true); - return MockOidcServer.json(200, wellKnownJson(server.httpUrl(DEVICE_PATH), server.httpUrl(TOKEN_PATH), server.httpUrl(""))); - } + // 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-DUP", "ID-DUP", null, 3600)); - }; - try (MockOidcServer server = new MockOidcServer(handler)) { - serverRef.set(server); - try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)))) { - Assert.assertEquals("ID-DUP", auth.getToken()); - } - Assert.assertFalse("discovery must be skipped when /settings advertises both endpoints", wellKnownHit.get()); - } - }); - } - - @Test(timeout = 30_000) - public void testFromQuestDbDiscoveryUrlPinRejectsForeignIssuerInDocument() throws Exception { - assertMemoryLeak(() -> { - // RFC 8414 section 3.3: discovery runs against the pinned discoveryUrl, and the document it - // returns declares an issuer - with co-located token and device endpoints - on an attacker - // origin. The discoveryUrl pins the identity provider to its own origin, so a document that - // vouches for a foreign issuer (and would route the device code and the long-lived refresh token - // there) must be rejected, rather than trusted just because its endpoints agree with its own - // self-declared issuer and the co-location check passes trivially. - MockOidcServer.Handler handler = (method, path, body) -> { - if (SETTINGS_PATH.equals(path)) { - // OIDC enabled, with a client id, but neither endpoint advertised - so both the token and - // the device endpoint must be read from the discovery document below - return MockOidcServer.json(200, "{\"config\":{" - + "\"acl.oidc.enabled\":true," - + "\"acl.oidc.client.id\":\"questdb\"," - + "\"acl.oidc.scope\":\"openid groups\"" - + "}}"); - } - // the document served at the pinned (loopback) discoveryUrl points everything at an attacker origin - return MockOidcServer.json(200, wellKnownJson( - "https://attacker.example/device", - "https://attacker.example/token", - "https://attacker.example")); - }; - try (MockOidcServer server = new MockOidcServer(handler)) { - try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().discoveryUrl(server.httpUrl(WELL_KNOWN_PATH)))) { - Assert.fail("expected the discoveryUrl pin to reject a document declaring a foreign issuer"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origin than the pinned discovery url")); - } - } - }); - } - - @Test(timeout = 30_000) - public void testFromQuestDbDiscoveryUrlPinRejectsOffOriginAdvertisedEndpoints() throws Exception { - assertMemoryLeak(() -> { - // /settings advertises both endpoints directly (so the discovery branch is skipped), but they do - // not belong to the pinned discoveryUrl origin; the discoveryUrl pin must reject them just as an - // issuer pin does, rather than let a compromised server redirect the sign-in to its chosen origin - 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().discoveryUrl("https://trusted-idp.example/.well-known/openid-configuration"))) { - Assert.fail("expected the discoveryUrl pin to reject the off-origin endpoints"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); + 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.getToken()); + } } } }); @@ -1341,7 +1273,7 @@ public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws 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("does not match the issuer origin")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("is not on the pinned identity-provider origin")); } } }); From c35d931aef264905b7b7f0cc9a734571dc398344 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 09:21:19 +0100 Subject: [PATCH 050/192] Fix Java 8 build breaks in OIDC device flow The JDK 8 CI job failed to compile two Java 9+ constructs that a local JDK 25 build silently accepts (newer rt.jar has the APIs, and -source without --release does not enforce the Java 8 platform): - Utf16Sink declared a private interface method, putUnicodeEscape, a Java 9 feature. Move it to DisplaySafe as a package-private static helper, putUnicodeEscape(Utf16Sink, int); the two putAsPrintable overloads now call it. DisplaySafe already owns the display-escaping policy and shares the package, so the helper stays out of the public API. The escape logic is moved verbatim. - OidcDeviceAuth.urlEncode called URLEncoder.encode(String, Charset), a Java 10 overload. Switch to the Java 8 encode(String, String) form and catch the (unreachable for UTF-8) UnsupportedEncodingException. Verified: the three changed files compile under --release 8, the full core module compiles, and LineSenderExceptionTest, LineHttpSenderErrorResponseTest, JsonLexerTest and OidcDeviceAuthTest stay green (156 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 10 ++++++-- .../questdb/client/std/str/DisplaySafe.java | 20 ++++++++++++++++ .../io/questdb/client/std/str/Utf16Sink.java | 23 ++----------------- 3 files changed, 30 insertions(+), 23 deletions(-) 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 index 9f5285033..46e7f3ca9 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -45,8 +45,8 @@ import io.questdb.client.std.str.DirectUtf8Sequence; import io.questdb.client.std.str.StringSink; +import java.io.UnsupportedEncodingException; import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.concurrent.locks.ReentrantLock; /** @@ -885,7 +885,13 @@ private static boolean settingsChannelIsPlaintext(Endpoint server) { } private static String urlEncode(String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8); + 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) { 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 index 71895f4ed..2577d1659 100644 --- a/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java +++ b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java @@ -24,6 +24,8 @@ 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 @@ -71,4 +73,22 @@ public static boolean isDisplaySafe(int cp) { 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/Utf16Sink.java b/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java index a706ee494..e824c6920 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: @@ -58,7 +56,7 @@ default void putAsPrintable(CharSequence nonPrintable) { put(nonPrintable.charAt(i + j)); } } else { - putUnicodeEscape(cp); + DisplaySafe.putUnicodeEscape(this, cp); } i += count; } @@ -71,7 +69,7 @@ default void putAsPrintable(char c) { if (DisplaySafe.isDisplaySafe(c)) { put(c); } else { - putUnicodeEscape(c); + DisplaySafe.putUnicodeEscape(this, c); } } @@ -102,21 +100,4 @@ default Utf16Sink putNonAscii(long lo, long hi) { Utf8s.utf8ToUtf16(lo, hi, this); return this; } - - // 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. - private void putUnicodeEscape(int cp) { - if (cp > 0xFFFF) { - putUnicodeEscape(Character.highSurrogate(cp)); - putUnicodeEscape(Character.lowSurrogate(cp)); - return; - } - put('\\'); - put('u'); - put(hexDigits[(cp >> 12) & 0xF]); - put(hexDigits[(cp >> 8) & 0xF]); - put(hexDigits[(cp >> 4) & 0xF]); - put(hexDigits[cp & 0xF]); - } } From 0493b4c628b8cd2c4d309e72388c4436e63bf89e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 14:21:30 +0100 Subject: [PATCH 051/192] Sanitize HTTP status and probe text in errors The PR sanitized the JSON error body a failed flush renders into a LineSenderException, but two adjacent untrusted-text paths in the same file still reached the message raw, so a hostile or proxied endpoint could splice ANSI/control/bidi bytes into a log line or terminal: - The "[http-status=...]" field was rendered with put(), not putAsPrintable(). The HTTP header parser copies the status-line token verbatim between the two spaces, and a non-3-char token bypasses the numeric status checks to reach the generic error path, so a crafted status line could carry an ESC. Route all four status renders through putAsPrintable(); the generic path is the exploitable one, the others are defensive. A normal 3-digit status renders unchanged. - Protocol-version detection concatenated the raw probe response body via String +. Build it through putAsPrintable() instead. Tests: - LineHttpSenderErrorResponseTest: a malformed status line carrying an ESC, and a protocol-probe error body with ESC/bidi/newline; both fail without the fix and pass with it. - DisplaySafeTest: direct coverage for the shared classifier (controls, format/bidi/BOM, supplementary tag chars and lone surrogates unsafe; printable ASCII and emoji safe) - it previously had none. - SenderBuilderErrorApiTest: the null-provider guard and the provider-then-username/password exclusion, neither tested before. - OidcDeviceAuthTest: a percent-encoded backslash (%5c/%5C) in the issuer-path scope check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../line/http/AbstractLineHttpSender.java | 12 +- .../test/SenderBuilderErrorApiTest.java | 21 ++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 4 + .../line/LineHttpSenderErrorResponseTest.java | 72 ++++++++++++ .../client/test/std/str/DisplaySafeTest.java | 106 ++++++++++++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java 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 835fa209d..3f9da72e4 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 @@ -334,7 +334,9 @@ 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"); } @@ -841,7 +843,7 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. // 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; } @@ -862,7 +864,7 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. // 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=").put(statusCode.asAsciiCharSequence()).put(']'); + .put(" [http-status=").putAsPrintable(statusCode.asAsciiCharSequence()).put(']'); client.disconnect(); throw ex; } @@ -1032,7 +1034,7 @@ public void onEvent(int code, CharSequence tag, int position) throws JsonExcepti private void drainAndReset(LineSenderException sink, DirectUtf8Sequence httpStatus) { assert state == State.INIT; - sink.putAsPrintable(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: ").putAsPrintable(errorIdSink); @@ -1074,7 +1076,7 @@ LineSenderException toException(Response chunkedRsp, DirectUtf8Sequence httpStat } // 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=").put(httpStatus.asAsciiCharSequence()).put(']'); + exception.putAsPrintable(jsonSink).put(" [http-status=").putAsPrintable(httpStatus.asAsciiCharSequence()).put(']'); reset(); return exception; } 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 fb7b844df..93ac401c9 100644 --- a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java +++ b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java @@ -264,6 +264,27 @@ public void testHttpTokenProviderIsMutuallyExclusiveWithOtherAuth() { } } + @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 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 index 37cda13cf..5a1bb1311 100644 --- 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 @@ -1863,6 +1863,10 @@ public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() thr Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%2%66evil/token", issuer)); Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%252fevil/token", issuer)); Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme\\evil/token", issuer)); + // a percent-encoded backslash (%5c / %5C) is the encoded form of the literal '\' above; the scan + // rejects it at the encoded level too, before decodePathSegments would fold it to '/' + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5cevil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5Cevil/token", issuer)); } @Test(timeout = 30_000) 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 index 4cdf665c0..c8ea1c563 100644 --- 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 @@ -51,6 +51,38 @@ public class LineHttpSenderErrorResponseTest { // U+202E RIGHT-TO-LEFT OVERRIDE: reorders displayed text (visual spoofing) private static final char RLO = 0x202e; + @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 testServerAuthErrorBodyControlAndBidiAreEscaped() throws Exception { assertMemoryLeak(() -> { @@ -83,6 +115,46 @@ public void testServerAuthErrorBodyControlAndBidiAreEscaped() throws Exception { }); } + @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(() -> { diff --git a/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java b/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java new file mode 100644 index 000000000..c65b6d49f --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java @@ -0,0 +1,106 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.std.str; + +import io.questdb.client.std.str.DisplaySafe; +import org.junit.Assert; +import org.junit.Test; + +/** + * Direct coverage for {@link DisplaySafe}, the single source of truth for whether a code point may be shown + * verbatim in a terminal or a log line. Both {@code Utf16Sink.putAsPrintable} and the OIDC display sanitizer + * delegate to it, so a regression here would silently weaken every display-escaping path - yet the classifier + * was previously exercised only transitively, for the few code points the integration tests happen to use. + *

    + * The unsafe code points (controls, Unicode format chars, surrogates, bidi controls, the BOM) are written as + * hex literals so this source stays pure ASCII and carries none of the chars it asserts on. + */ +public class DisplaySafeTest { + + @Test + public void testC0C1ControlsAndDelAreUnsafe() { + // C0 (incl. TAB/LF/CR/ESC), DEL, and the C1 block: every ISO control must be escaped + int[] unsafe = {0x00, 0x07, 0x08, 0x09, 0x0A, 0x0D, 0x1B, 0x1F, 0x7F, 0x80, 0x90, 0x9F}; + for (int cp : unsafe) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertFalse("control " + hex + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + Assert.assertTrue("control " + hex + " must be unsafe", DisplaySafe.isUnsafeForDisplay(cp)); + } + } + + @Test + public void testFormatBidiAndBomAreUnsafe() { + // Cf format chars and the explicit bidi/BOM set that reorder, hide, or mark text - including the + // supplementary-plane "tag" chars that arrive as a surrogate pair and must be judged whole + int[] unsafe = { + 0x00AD, // soft hyphen + 0x200B, // zero-width space + 0x200E, 0x200F, // LRM, RLM + 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // LRE, RLE, PDF, LRO, RLO + 0x2066, 0x2067, 0x2068, 0x2069, // LRI, RLI, FSI, PDI + 0xFEFF, // BOM / zero-width no-break space + 0xE0001, // language tag + 0xE0020, 0xE007F // tag space, cancel tag + }; + for (int cp : unsafe) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertTrue("format " + hex + " must be unsafe", DisplaySafe.isUnsafeForDisplay(cp)); + Assert.assertFalse("format " + hex + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + } + } + + @Test + public void testLoneSurrogatesAreUnsafe() { + // a lone surrogate half has no displayable meaning; the code-point classifier must reject it + int[] surrogates = {0xD800, 0xDBFF, 0xDC00, 0xDFFF}; + for (int cp : surrogates) { + Assert.assertFalse("surrogate 0x" + Integer.toHexString(cp) + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + } + } + + @Test + public void testPrintableAsciiIsSafe() { + // the fast-path range 0x20..0x7e is the overwhelmingly common case and must always pass + for (int cp = 0x20; cp <= 0x7e; cp++) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertTrue("printable ASCII " + hex + " must be safe", DisplaySafe.isDisplaySafe(cp)); + Assert.assertFalse("printable ASCII " + hex + " must be safe", DisplaySafe.isUnsafeForDisplay(cp)); + } + } + + @Test + public void testPrintableSupplementaryCharsAreSafe() { + // a normal supplementary char (emoji, CJK extension) is neither control, format, nor surrogate and + // stays safe, so the classifier does not over-escape legitimate non-BMP text + int[] safe = { + 0x1F600, // grinning face emoji + 0x1F4A9, // pile of poo + 0x20000 // CJK Extension B ideograph + }; + for (int cp : safe) { + Assert.assertTrue("supplementary 0x" + Integer.toHexString(cp) + " must be safe", DisplaySafe.isDisplaySafe(cp)); + } + } +} From a4875f223300bd4c53171615387dc98b8d71cfd8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 14:51:54 +0100 Subject: [PATCH 052/192] Tighten OIDC token-kind and status validation Three minor follow-ups to the device-flow review: - storeTokens validated both the access_token and the id_token for control/non-ASCII chars, but getToken() only ever serves (and sends) one of them. A stray char in the unused kind - which never reaches a header or a PG-wire password - aborted an otherwise-usable grant. Validate only the served kind (groupsInToken ? idToken : accessToken); the served kind is still strictly checked. - The HTTP status classifiers keyed off the first digit with only a length > 0 guard, so a malformed short all-digit status like "2" or "5" could be read as a 2xx/5xx. readResponse already guarantees bare digits; require exactly 3 of them, so a malformed-length status falls through to the terminal reject path instead of being trusted. - getTokenSilently's javadoc claimed it "never blocks". It does not wait on interactive input or behind another thread's sign-in, but it can make one synchronous refresh round-trip bounded by httpTimeoutMillis. Reword to say so, matching the HttpTokenProvider contract. Tests (both proven to fail without their fix): - a control char in the unused id_token (groupsInToken=false) no longer aborts the grant; - a 1-digit "2" status from the token endpoint is rejected, not accepted as success, and the smuggled token never surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 51 +++++++++------ .../test/cutlass/auth/OidcDeviceAuthTest.java | 63 +++++++++++++++++++ 2 files changed, 94 insertions(+), 20 deletions(-) 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 index 46e7f3ca9..d9add87e1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -440,16 +440,18 @@ public String getToken() { } /** - * Like {@link #getToken()} but never starts the interactive device flow and never blocks: 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::getTokenSilently)}, where an interactive prompt - * is inappropriate and a stalled flush unacceptable. Call {@link #getToken()} once to sign in first. + * Like {@link #getToken()} 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::getTokenSilently)}, where an interactive prompt is + * inappropriate. Call {@link #getToken()} once to sign in first. *

    - * To keep the flush path responsive it returns or throws promptly - it never waits for an interactive - * {@link #getToken()} on another thread (which would stall the flush for the whole device-code - * lifetime). While such a sign-in runs there is no token to return anyway, so it throws and the caller - * should retry once the sign-in completes. + * It does not wait behind an interactive {@link #getToken()} 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 is not, however, instantaneous - when the cached + * token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by + * {@link Builder#httpTimeoutMillis(int)} (30s by default). 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 @@ -979,19 +981,23 @@ private HttpClient httpClient(boolean isTls) { } private boolean isHttpStatusSuccess() { - // responseStatus is the numeric HTTP status captured by readResponse; a 2xx starts with '2' - return responseStatus.length() > 0 && responseStatus.charAt(0) == '2'; + // 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) - return responseStatus.length() > 0 && responseStatus.charAt(0) == '4' && !Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus); + // 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. - return responseStatus.length() > 0 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus)); + // 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 void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) { @@ -1241,11 +1247,16 @@ private void sleepBetweenPolls(long millis) { } private void storeTokens(TokenResponseParser parser) { - // 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 - validateTokenChars(parser.accessToken, "access_token"); - validateTokenChars(parser.idToken, "id_token"); + // 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"); + } accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null; idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null; // a refresh response usually omits a new refresh token; keep the current one in that case 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 index 5a1bb1311..db27ef29c 100644 --- 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 @@ -3176,6 +3176,69 @@ 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 getToken() 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.getToken()); + } + }); + } + + @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.getToken(); + 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")); + } + } + }); + } + // Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next // getToken()/getTokenSilently() 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 From e1f3d538abbc0bba42acfe5ff63265d07a138cc7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 15:09:37 +0100 Subject: [PATCH 053/192] Fix Java 8 build: replace String.repeat in test OidcDeviceAuthTest called String.repeat(int) at two sites, a Java 11 API. The client has a Java 8 floor (the JDK 8 CI job compiles, tests, and releases the artifact), so the JDK 8 build failed to compile while the JDK 25 smoke job passed and hid the break. Replace both calls with the existing TestUtils.repeat(CharSequence, int) Java 8 stand-in, which is already imported in the test and used by other client tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index db27ef29c..0c70a23de 100644 --- 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 @@ -441,7 +441,7 @@ public void testChallengeStripsSupplementaryPlaneFormatChars() throws Exception public void testChunkedTokenResponseParses() throws Exception { assertMemoryLeak(() -> { // real IdPs use Transfer-Encoding: chunked; a multi-KB id token split across chunks must parse - String idToken = "a".repeat(3000); + String idToken = TestUtils.repeat("a", 3000); MockOidcServer.Handler handler = (method, path, body) -> { if (DEVICE_PATH.equals(path)) { return MockOidcServer.chunkedJson(200, deviceAuthorizationJson(1, 300)); @@ -1877,7 +1877,7 @@ public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exc // 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\":\"" + "a".repeat(4000) + "\"}"; + 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); From 3d10a4e84ca7a297994e3aeefa0360216b604429 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 18:34:27 +0100 Subject: [PATCH 054/192] QWP egress token provider and OIDC API rename Add QwpQueryClient.withBearerTokenProvider(HttpTokenProvider): the egress query client now accepts an on-demand token provider, the same HttpTokenProvider the ingress Sender uses, so OidcDeviceAuth::getToken plugs into both. runUpgradeWithTimeout resolves the Authorization header at every WebSocket upgrade, so the initial connect and each failover reconnect present a freshly refreshed token; a throwing provider fails that connection attempt, matching the ingress sender. The setter is mutually exclusive with withBearerToken/withBasicAuth and validates each token before it reaches the header. Rename the OidcDeviceAuth methods so the safe, non-blocking accessor takes the plain name and the interactive step is explicit: getToken() is now signIn() (interactive sign-in, may block); getTokenSilently() is now getToken() (cached/refresh, never prompts). getAuthorizationHeaderValue() keeps its blocking behavior by calling signIn(). Update the tests, examples, and doc references across the client. The Python client is left unchanged for now. Print the egress timestamp column as a formatted Instant in OIDCAuthExample instead of the raw microsecond long. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 2 +- .../main/java/io/questdb/client/Sender.java | 2 +- .../client/cutlass/auth/OidcDeviceAuth.java | 46 +-- .../line/http/AbstractLineHttpSender.java | 4 +- .../cutlass/qwp/client/QwpQueryClient.java | 65 +++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 284 +++++++++--------- .../line/LineHttpSenderTokenProviderTest.java | 4 +- .../QwpQueryClientPostConnectGuardTest.java | 2 + .../QwpQueryClientTokenProviderTest.java | 122 ++++++++ .../client/test/example/OIDCAuthExample.java | 70 +++++ .../example/sender/OidcDeviceFlowExample.java | 4 +- 11 files changed, 429 insertions(+), 176 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java create mode 100644 core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index de221c682..6ac1bd096 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -29,7 +29,7 @@ /** * Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a - * freshly refreshed token - e.g. {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender + * freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken} - keeps a long-lived sender * authenticated as the token rotates, without rebuilding it. Over HTTP the sender calls * {@link #getToken()} as it builds each request; over WebSocket it calls it once per connection * handshake, on the initial connect and again on every reconnect. diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index a60f05da4..dfdfc68e1 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2018,7 +2018,7 @@ public LineSenderBuilder httpToken(String token) { /** * 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::getTokenSilently)}. + * 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 the initial connection handshake runs during 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 index d9add87e1..6e76366ad 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -65,7 +65,7 @@ * Typical use, discovering everything from the QuestDB server: *

    {@code
      * try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
    - *     String token = auth.getToken(); // signs in on first use, then caches and refreshes
    + *     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 ...
      * }
      * }
    @@ -79,11 +79,11 @@ * .groupsInToken(true) * .build(); * } - * {@link #getToken()} serves a cached token while valid, silently refreshes when a refresh token + * {@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 #getToken()} or {@link #clearCache()} blocks - * behind it - but {@link #getTokenSilently()} never waits: it fails fast with an + * lifetime (up to 30 minutes), so a concurrent {@link #signIn()} or {@link #clearCache()} blocks + * behind it - but {@link #getToken()} never waits: it fails fast with an * {@link OidcAuthException} so a request/flush path never stalls. 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 @@ -153,8 +153,8 @@ public class OidcDeviceAuth implements QuietCloseable { private final StringSink formSink = new StringSink(); private final boolean groupsInToken; private final int httpTimeoutMillis; - // serializes getToken()/getTokenSilently()/clearCache()/close(); getToken() holds it for the whole - // interactive flow, getTokenSilently() uses tryLock so the flush path never stalls behind a sign-in + // 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(); @@ -351,7 +351,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt } /** - * Drops any cached token so the next {@link #getToken()} starts a fresh interactive sign-in. + * Drops any cached token so the next {@link #signIn()} starts a fresh interactive sign-in. */ public void clearCache() { lock.lock(); @@ -368,7 +368,7 @@ public void clearCache() { } /** - * Frees the network connections and native buffers this instance holds. If a {@link #getToken()} + * 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 @@ -378,12 +378,12 @@ public void clearCache() { * {@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 #getToken()} and - * {@link #clearCache()} throw. + * 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: getToken() holds it for the whole flow, so signal the + // 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 @@ -399,11 +399,11 @@ public void close() { } /** - * @return {@code "Bearer " + getToken()}, ready to use as the value of an HTTP + * @return {@code "Bearer " + signIn()}, ready to use as the value of an HTTP * {@code Authorization} header. */ public String getAuthorizationHeaderValue() { - return "Bearer " + getToken(); + return "Bearer " + signIn(); } /** @@ -415,11 +415,11 @@ public String getAuthorizationHeaderValue() { * @throws OidcAuthException if the interactive flow fails, times out, or the identity provider * does not return the expected token */ - public String getToken() { + public String signIn() { lock.lock(); try { throwIfClosed(); - // only the kind of token getToken() actually serves counts as a cache hit; a grant that + // 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 re-run the flow rather than report the unusable grant as // valid and have selectToken() throw on this and every later call @@ -440,13 +440,13 @@ public String getToken() { } /** - * Like {@link #getToken()} but never starts the interactive device flow, never prompts, and never waits + * 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::getTokenSilently)}, where an interactive prompt is - * inappropriate. Call {@link #getToken()} once to sign in first. + * {@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 #getToken()} running on another thread (which would stall + * 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 is not, however, instantaneous - when the cached * token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by @@ -458,9 +458,9 @@ public String getToken() { * not be refreshed without an interactive sign-in, or if a sign-in or * refresh is already in progress on another thread */ - public String getTokenSilently() { + public String getToken() { throwIfClosed(); - // never wait on the flush path: getToken()'s sign-in holds the lock for the whole device-code + // never wait on the flush path: signIn()'s sign-in holds the lock for the whole device-code // lifetime (up to 30 minutes), so tryLock and fail fast if held. A sign-in in progress means there // is no token to serve yet, so the caller gets a prompt exception to retry rather than a stalled // flush @@ -477,9 +477,9 @@ public String getTokenSilently() { if (refreshToken != null && tryRefresh()) { return selectToken(); } - throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call getToken() to sign in again"); + 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 getToken() to sign in before using getTokenSilently()"); + throw new OidcAuthException("no token has been obtained yet; call signIn() to sign in before using getToken()"); } finally { lock.unlock(); } 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 3f9da72e4..188ba9ecb 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 @@ -412,7 +412,7 @@ public static AbstractLineHttpSender createLineSender( if (httpTokenProvider != null) { // The constructor already built the initial request without a token. Defer the first // getToken() off this build path to the first row (table()), so a provider that signs in - // lazily - e.g. OidcDeviceAuth::getTokenSilently - can be wired before sign-in completes, + // lazily - e.g. OidcDeviceAuth::getToken - can be wired before sign-in completes, // and the token pull stays on the use/flush path the provider documents. sender.httpTokenProvider = httpTokenProvider; sender.isTokenPending = true; @@ -816,7 +816,7 @@ private boolean rowAdded() { private void stampTokenIfPending() { if (isTokenPending) { // The construct/flush path deferred the token so a lazily-signing-in provider (e.g. - // OidcDeviceAuth::getTokenSilently) could be wired before sign-in completed, and so a provider + // 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 // rebuild the still-empty request to carry the token before any row data goes in. Clear the // flag only after newRequest(true) succeeds: a pull that throws (not signed in yet, or a failed 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 1706401e7..f5f6d9a49 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,6 +25,7 @@ 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; @@ -285,6 +286,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; @@ -838,11 +844,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(); } /** @@ -1003,6 +1010,9 @@ public QwpQueryClient withAuthTimeout(long authTimeoutMs) { */ 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"); } @@ -1020,6 +1030,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"); } @@ -1027,6 +1040,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; @@ -1744,11 +1787,27 @@ private void reconnectViaTracker() { + ", lastError=" + (lastError == null ? "" : lastError.getMessage()) + ']'); } + private String resolveAuthorizationHeader() { + // With a token provider, re-query it at each upgrade 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) propagates and fails this connection + // attempt, matching the QWP ingress sender. + if (tokenProvider != null) { + CharSequence token = tokenProvider.getToken(); + HttpTokenProvider.validateToken(token); + return "Bearer " + token; + } + return authorizationHeader; + } + private void runUpgradeWithTimeout(Endpoint ep) { int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); + String authHeader = resolveAuthorizationHeader(); try { webSocketClient.connect(ep.host, ep.port); - webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authorizationHeader); + webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader); } catch (HttpClientException ex) { if (ex.isTimeout()) { HttpClientException timeout = new HttpClientException("WebSocket upgrade to ") 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 index 0c70a23de..1973bd72b 100644 --- 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 @@ -82,7 +82,7 @@ public void testAccessDeniedSurfacesOauthError() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertEquals("access_denied", e.getOauthError()); @@ -115,7 +115,7 @@ public void testAllControlVerificationUriCompleteTreatedAsAbsent() throws Except AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-OK", auth.getToken()); + Assert.assertEquals("ACCESS-OK", auth.signIn()); DeviceAuthorizationChallenge challenge = shown.get(); Assert.assertNotNull(challenge); Assert.assertNull(challenge.getVerificationUriComplete()); @@ -145,7 +145,7 @@ public void testAllControlVerificationUriRejectedAsIncomplete() throws Exception try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an all-control verification_uri to be rejected as incomplete"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete")); @@ -175,7 +175,7 @@ public void testAudienceParameterSentToDeviceEndpoint() throws Exception { .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - Assert.assertEquals("ACCESS-AUD", auth.getToken()); + Assert.assertEquals("ACCESS-AUD", auth.signIn()); Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb")); } }); @@ -205,9 +205,9 @@ public void testAudienceSentOnRefresh() throws Exception { .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + Assert.assertEquals("ACCESS-1", auth.signIn()); expireCachedToken(auth); // force the silent-refresh path on the next call - Assert.assertEquals("ACCESS-2", auth.getToken()); + Assert.assertEquals("ACCESS-2", auth.signIn()); Assert.assertTrue(refreshBody.get(), refreshBody.get().contains("audience=api%3A%2F%2Fquestdb")); } }); @@ -309,7 +309,7 @@ public void testChallengeStripsBidiAndZeroWidthFromDisplayFields() throws Except AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-OK", auth.getToken()); + 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 @@ -345,7 +345,7 @@ public void testChallengeStripsControlCharactersFromDisplayFields() throws Excep AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-OK", auth.getToken()); + 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 @@ -384,7 +384,7 @@ public void testChallengeStripsLoneSurrogates() throws Exception { AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-OK", auth.getToken()); + 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 @@ -424,7 +424,7 @@ public void testChallengeStripsSupplementaryPlaneFormatChars() throws Exception AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-OK", auth.getToken()); + 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 @@ -451,7 +451,7 @@ public void testChunkedTokenResponseParses() throws Exception { 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.getToken()); + Assert.assertEquals(idToken, auth.signIn()); } }); } @@ -459,7 +459,7 @@ public void testChunkedTokenResponseParses() throws Exception { @Test(timeout = 30_000) public void testClearCacheForcesFreshSignIn() throws Exception { assertMemoryLeak(() -> { - // clearCache() must drop the cached token AND the refresh token, so the next getToken() runs a + // 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(); @@ -476,10 +476,10 @@ public void testClearCacheForcesFreshSignIn() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + 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.getToken()); + 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()); } @@ -513,13 +513,13 @@ public void testClockSkewCappedAtHalfTokenLifetime() throws Exception { .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.getToken()); - Assert.assertEquals("ACCESS-1", auth.getToken()); + 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, getToken() takes the silent-refresh path + // once the token is genuinely past expiry, signIn() takes the silent-refresh path expireCachedToken(auth); - Assert.assertEquals("ACCESS-2", auth.getToken()); + Assert.assertEquals("ACCESS-2", auth.signIn()); Assert.assertEquals(1, refreshCalls.get()); } }); @@ -528,7 +528,7 @@ public void testClockSkewCappedAtHalfTokenLifetime() throws Exception { @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 getToken() promptly, instead of letting + // 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) -> { @@ -543,8 +543,8 @@ public void testCloseCancelsInFlightSignIn() throws Exception { OidcDeviceAuth auth = newAuth(server, false, challenge -> polling.countDown())) { Thread signIn = new Thread(() -> { try { - auth.getToken(); - outcome.set(new AssertionError("getToken() should have been cancelled by close()")); + auth.signIn(); + outcome.set(new AssertionError("signIn() should have been cancelled by close()")); } catch (Throwable t) { outcome.set(t); } @@ -555,7 +555,7 @@ public void testCloseCancelsInFlightSignIn() throws Exception { Assert.assertTrue("the sign-in did not reach the polling stage", polling.await(10, TimeUnit.SECONDS)); auth.close(); signIn.join(10_000); - Assert.assertFalse("getToken() did not return promptly after close()", signIn.isAlive()); + 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")); @@ -566,7 +566,7 @@ public void testCloseCancelsInFlightSignIn() throws Exception { @Test(timeout = 30_000) public void testConcurrentGetTokenStartsSingleSignIn() throws Exception { assertMemoryLeak(() -> { - // several callers race getToken() on a fresh instance; the synchronized method must serialize + // 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) -> { @@ -590,11 +590,11 @@ public void testConcurrentGetTokenStartsSingleSignIn() throws Exception { ready.countDown(); try { go.await(); - tokens[idx] = auth.getToken(); + tokens[idx] = auth.signIn(); } catch (Throwable t) { error.set(t); } - }, "oidc-getToken-" + i); + }, "oidc-signIn-" + i); workers[i].setDaemon(true); workers[i].start(); } @@ -628,7 +628,7 @@ public void testDeviceCodeLifetimeClamped() throws Exception { }; try (MockOidcServer server = new MockOidcServer(missingExpiry); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-DEFAULT-TTL", auth.getToken()); + Assert.assertEquals("ACCESS-DEFAULT-TTL", auth.signIn()); Assert.assertEquals(600, shown.get().getExpiresInSeconds()); } MockOidcServer.Handler absurdExpiry = (method, path, body) -> { @@ -639,7 +639,7 @@ public void testDeviceCodeLifetimeClamped() throws Exception { }; try (MockOidcServer server = new MockOidcServer(absurdExpiry); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-CAPPED-TTL", auth.getToken()); + Assert.assertEquals("ACCESS-CAPPED-TTL", auth.signIn()); Assert.assertEquals(1800, shown.get().getExpiresInSeconds()); } }); @@ -654,7 +654,7 @@ public void testDeviceEndpointReturnsOauthError() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertEquals("invalid_client", e.getOauthError()); @@ -685,7 +685,7 @@ public void testDeviceFlowHappyPath() throws Exception { AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + Assert.assertEquals("ACCESS-1", auth.signIn()); Assert.assertEquals("Bearer ACCESS-1", auth.getAuthorizationHeaderValue()); Assert.assertEquals(2, tokenCalls.get()); @@ -728,7 +728,7 @@ public void testNonNumericStatusCodeRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { - auth.getToken(); + auth.signIn(); Assert.fail("expected a malformed status code to be rejected"); } catch (OidcAuthException e) { String msg = e.getMessage(); @@ -760,7 +760,7 @@ public void testNonNumericStatusCodeRejectedDuringPolling() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a malformed status code on the poll path to be rejected"); } catch (OidcAuthException e) { String msg = e.getMessage(); @@ -796,7 +796,7 @@ public void testDiscoveryDefaultsScopeToOpenid() throws Exception { try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { - Assert.assertEquals("ACCESS-SCOPE", auth.getToken()); + Assert.assertEquals("ACCESS-SCOPE", auth.signIn()); Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("groups")); } @@ -838,7 +838,7 @@ public void testDiscoveryIgnoresPreferencesKeys() throws Exception { 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.getToken()); + Assert.assertEquals("ACCESS-TRUSTED", auth.signIn()); Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("INJECTED")); } @@ -873,7 +873,7 @@ public void testDiscoveryReadsAudience() throws Exception { try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { - Assert.assertEquals("ACCESS-AUD-D", auth.getToken()); + Assert.assertEquals("ACCESS-AUD-D", auth.signIn()); Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb")); } } @@ -974,7 +974,7 @@ public void testDuplicateJsonKeysDoNotConcatenate() throws Exception { 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.getToken()); + 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()); } @@ -1061,7 +1061,7 @@ public void testEscapedDeviceCodeRoundTripsDecoded() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-DC", auth.getToken()); + 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")); } @@ -1082,7 +1082,7 @@ public void testEscapedErrorDescriptionDecoded() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertEquals("access_denied", e.getOauthError()); @@ -1116,7 +1116,7 @@ public void testEscapedVerificationUrlIsUnescapedForDisplay() throws Exception { AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-ESC", auth.getToken()); + Assert.assertEquals("ACCESS-ESC", auth.signIn()); DeviceAuthorizationChallenge challenge = shown.get(); Assert.assertNotNull(challenge); Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri()); @@ -1149,8 +1149,8 @@ public void testFromQuestDbDiscoversDeviceEndpointFromIssuer() throws Exception 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 getToken() returns the id token - Assert.assertEquals("ID-WK", auth.getToken()); + // settings advertise groups.encoded.in.token=true, so signIn() returns the id token + Assert.assertEquals("ID-WK", auth.signIn()); } } }); @@ -1201,8 +1201,8 @@ public void testFromQuestDbDiscoveryRunsFlow() throws Exception { 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 getToken() must return the id token - Assert.assertEquals("ID-D", auth.getToken()); + // discovery advertises groups.encoded.in.token=true, so signIn() must return the id token + Assert.assertEquals("ID-D", auth.signIn()); } } }); @@ -1250,7 +1250,7 @@ public void testFromQuestDbIssuerPinAcceptsOffOriginDiscoveredEndpoints() throws 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.getToken()); + Assert.assertEquals("ACCESS-OFF", auth.signIn()); } } } @@ -1358,7 +1358,7 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except 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 getToken() + // failure out of signIn() AtomicInteger deviceCalls = new AtomicInteger(); AtomicInteger deviceCodeGrants = new AtomicInteger(); MockOidcServer.Handler handler = (method, path, body) -> { @@ -1377,11 +1377,11 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + 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.getToken()); + Assert.assertEquals("ACCESS-2", auth.signIn()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); } }); @@ -1390,8 +1390,8 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except @Test(timeout = 30_000) public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exception { assertMemoryLeak(() -> { - // an interactive getToken() is parked polling (authorization_pending), holding the instance - // lock for the whole device-code lifetime. A flush-path getTokenSilently() on another thread + // 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) -> { @@ -1405,7 +1405,7 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc OidcDeviceAuth auth = newAuth(server, false, challenge -> polling.countDown())) { Thread signIn = new Thread(() -> { try { - auth.getToken(); + auth.signIn(); } catch (Throwable ignore) { // expected: cancelled by close() at the end of the test } @@ -1415,15 +1415,15 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc 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)); - // getTokenSilently() must return control promptly (here: throw), NOT block ~10s until - // the device code expires and getToken() releases the lock + // getToken() must return control promptly (here: throw), NOT block ~10s until + // the device code expires and signIn() releases the lock long startNanos = System.nanoTime(); try { - auth.getTokenSilently(); - Assert.fail("expected getTokenSilently() to fail fast while a sign-in is in progress"); + auth.getToken(); + Assert.fail("expected getToken() to fail fast while a sign-in is in progress"); } catch (OidcAuthException e) { long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; - Assert.assertTrue("getTokenSilently() blocked " + elapsedMillis + "ms behind the in-flight sign-in", + Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight sign-in", elapsedMillis < 2_000); Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); } @@ -1439,8 +1439,8 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Exception { assertMemoryLeak(() -> { // the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not - // just an interactive sign-in: getTokenSilently() must fail fast rather than queue behind it. The - // cached token is forced expired so getTokenSilently() refreshes; the token endpoint blocks the + // just an interactive sign-in: getToken() must fail fast rather than queue behind it. The + // cached token is forced expired so getToken() refreshes; the token endpoint blocks the // refresh response until the test releases it, pinning the lock on the refresher thread while the // second caller races for it CountDownLatch refreshInFlight = new CountDownLatch(1); @@ -1470,11 +1470,11 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - auth.getToken(); // sign in once: caches ACCESS-1 and a refresh token - expireCachedToken(auth); // so the refresher thread's getTokenSilently() takes the refresh path + auth.signIn(); // sign in once: caches ACCESS-1 and a refresh token + expireCachedToken(auth); // so the refresher thread's getToken() takes the refresh path Thread refresher = new Thread(() -> { try { - auth.getTokenSilently(); + auth.getToken(); } catch (Throwable ignore) { // the refresh completes once released; a late error here is irrelevant to this test } @@ -1483,14 +1483,14 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti refresher.start(); try { Assert.assertTrue("the silent refresh did not start", refreshInFlight.await(10, TimeUnit.SECONDS)); - // a refresh holds the lock now; getTokenSilently() on this thread must fail fast, not block + // a refresh holds the lock now; getToken() on this thread must fail fast, not block long startNanos = System.nanoTime(); try { - auth.getTokenSilently(); - Assert.fail("expected getTokenSilently() to fail fast while a refresh is in progress"); + auth.getToken(); + Assert.fail("expected getToken() to fail fast while a refresh is in progress"); } catch (OidcAuthException e) { long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; - Assert.assertTrue("getTokenSilently() blocked " + elapsedMillis + "ms behind the in-flight refresh", + Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight refresh", elapsedMillis < 2_000); Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); } @@ -1505,7 +1505,7 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti @Test(timeout = 30_000) public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { assertMemoryLeak(() -> { - // getTokenSilently() returns the cached token, silently refreshes it when it expires, and never + // 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(); @@ -1524,28 +1524,28 @@ public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) { - // before any sign-in, getTokenSilently() must not prompt - it throws + // before any sign-in, getToken() must not prompt - it throws try { - auth.getTokenSilently(); - Assert.fail("expected getTokenSilently() to fail before sign-in"); + auth.getToken(); + Assert.fail("expected getToken() to fail before sign-in"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("no token")); } // sign in once interactively - Assert.assertEquals("ACCESS-1", auth.getToken()); + Assert.assertEquals("ACCESS-1", auth.signIn()); expireCachedToken(auth); - // the cached token is expired, so getTokenSilently() refreshes silently - Assert.assertEquals("ACCESS-2", auth.getTokenSilently()); - // now make the refresh fail; getTokenSilently() must throw, not start the device flow + // 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); try { - auth.getTokenSilently(); - Assert.fail("expected getTokenSilently() to fail when the refresh is rejected"); + auth.getToken(); + Assert.fail("expected getToken() to fail when the refresh is rejected"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("interactive sign-in")); } - // the device flow ran exactly once (the initial getToken), and the user was prompted once + // 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()); } @@ -1556,7 +1556,7 @@ public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { 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); getToken() must fail with an actionable message + // (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)); @@ -1566,7 +1566,7 @@ public void testGroupsInTokenButNoIdTokenFails() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); @@ -1586,7 +1586,7 @@ public void testGroupsInTokenReturnsIdToken() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { - Assert.assertEquals("ID-X", auth.getToken()); + Assert.assertEquals("ID-X", auth.signIn()); } }); } @@ -1596,7 +1596,7 @@ public void testHttpSenderProviderFailureAfterFlushDoesNotCorruptSender() throws 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::getTokenSilently when a refresh fails) must NOT turn an already-successful + // 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, ""); @@ -1678,7 +1678,7 @@ public void testIncompleteDeviceResponseRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete device authorization")); @@ -1765,7 +1765,7 @@ public void testIssuerPathScopingAcceptsEndpointsUnderIssuerPath() throws Except 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.getToken()); + Assert.assertEquals("ACCESS-REALM", auth.signIn()); } } }); @@ -2000,7 +2000,7 @@ public void testMalformedEndpointDoesNotLeakNativeMemory() { @Test(timeout = 30_000) public void testNoAccessTokenWhenGroupsDisabledFails() throws Exception { assertMemoryLeak(() -> { - // groups not in token, but the IdP returns only an id token; getToken() must fail + // 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)); @@ -2010,7 +2010,7 @@ public void testNoAccessTokenWhenGroupsDisabledFails() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("no access_token")); @@ -2031,7 +2031,7 @@ public void testNonSuccessDeviceAuthorizationResponseRejected() throws Exception try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, challenge -> prompted.set(true))) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected the non-2xx device authorization response to be rejected"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response from the device authorization endpoint")); @@ -2055,7 +2055,7 @@ public void testNullAccessTokenNotServedAsLiteralNull() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - String token = auth.getToken(); + String token = auth.signIn(); Assert.fail("a JSON null access_token must not be served as the literal token \"null\" [got=" + token + "]"); } catch (OidcAuthException e) { // null is absent, so a 2xx with no token is a definitive but malformed answer @@ -2085,7 +2085,7 @@ public void testNullJsonErrorIsTreatedAsAbsent() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-OK", auth.getToken()); + Assert.assertEquals("ACCESS-OK", auth.signIn()); } }); } @@ -2109,7 +2109,7 @@ public void testNullPromptDefaultsToSystemOut() throws Exception { .allowInsecureTransport(true) .build()) { // no NPE: the flow runs to completion with the default SYSTEM_OUT prompt - Assert.assertEquals("ACCESS-NP", auth.getToken()); + Assert.assertEquals("ACCESS-NP", auth.signIn()); } }); } @@ -2126,7 +2126,7 @@ public void testOauthErrorMessageStripsBidiControls() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertEquals("access_denied", e.getOauthError()); @@ -2150,7 +2150,7 @@ public void testOauthErrorMessageStripsControlChars() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertEquals("access_denied", e.getOauthError()); @@ -2178,7 +2178,7 @@ public void testOutOfRangePollIntervalAndExpiryAreClamped() throws Exception { AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-CLAMP", auth.getToken()); + 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 @@ -2220,7 +2220,7 @@ public void testPollAbortDropsDirtyConnectionAndReconnects() throws Exception { // 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 getToken throws. + // 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)) { @@ -2242,7 +2242,7 @@ public void testPollAbortDropsDirtyConnectionAndReconnects() throws Exception { .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - Assert.assertEquals("ACCESS-RECONNECTED", auth.getToken()); + Assert.assertEquals("ACCESS-RECONNECTED", auth.signIn()); Assert.assertEquals(2, tokenCalls.get()); } }); @@ -2264,7 +2264,7 @@ public void testPollIntervalClampedTo60() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected the device code to expire"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); @@ -2288,7 +2288,7 @@ public void testRateLimited429WithTerminalErrorAbortsImmediately() throws Except try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected the terminal OAuth error to abort despite the 429 status"); } catch (OidcAuthException e) { Assert.assertEquals("access_denied", e.getOauthError()); @@ -2313,7 +2313,7 @@ public void testRateLimitedTokenEndpointBacksOffInsteadOfFailingFast() throws Ex try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected the device code to expire while the token endpoint kept returning 429"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); @@ -2345,7 +2345,7 @@ public void testPersistentTransportFailureKeepsPollingToDeadline() throws Except .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - auth.getToken(); + 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 @@ -2370,7 +2370,7 @@ public void testPersistent5xxDuringPollingKeepsPollingToDeadline() throws Except try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected the device code to expire while the token endpoint returned 503"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); @@ -2394,7 +2394,7 @@ public void testTerminal4xxDuringPollingFailsFast() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a terminal 4xx to fail fast"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); @@ -2426,10 +2426,10 @@ public void testRefreshErrorFallsBackToInteractiveFlow() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + 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.getToken()); + Assert.assertEquals("ACCESS-2", auth.signIn()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); } }); @@ -2459,13 +2459,13 @@ public void testRefreshKeepsExistingRefreshTokenWhenOmitted() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + Assert.assertEquals("ACCESS-1", auth.signIn()); expireCachedToken(auth); // first refresh omits refresh_token, so REFRESH-1 must be kept - Assert.assertEquals("ACCESS-R1", auth.getToken()); + 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.getToken()); + Assert.assertEquals("ACCESS-R2", auth.signIn()); Assert.assertEquals("no extra interactive sign-in", 1, deviceCalls.get()); Assert.assertEquals(2, refreshCalls.get()); } @@ -2496,11 +2496,11 @@ public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Ex }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + 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.getToken()); + Assert.assertEquals("ACCESS-2", auth.signIn()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); } }); @@ -2509,7 +2509,7 @@ public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Ex @Test(timeout = 30_000) public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Exception { assertMemoryLeak(() -> { - // groups are encoded in the token (the default enterprise config), so getToken() serves the + // 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(); @@ -2531,11 +2531,11 @@ public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Excepti }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { - Assert.assertEquals("ID-1", auth.getToken()); + 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.getToken()); + Assert.assertEquals("ID-2", auth.signIn()); Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); } }); @@ -2558,7 +2558,7 @@ public void testServerErrorDuringPollingRetries() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-RECOVERED-5XX", auth.getToken()); + Assert.assertEquals("ACCESS-RECOVERED-5XX", auth.signIn()); Assert.assertEquals(2, tokenCalls.get()); } }); @@ -2583,10 +2583,10 @@ public void testSilentRefreshWhenTokenExpired() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) { - Assert.assertEquals("ACCESS-1", auth.getToken()); + Assert.assertEquals("ACCESS-1", auth.signIn()); expireCachedToken(auth); // the cached token is expired, so the second call refreshes silently - Assert.assertEquals("ACCESS-2", auth.getToken()); + 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()); } @@ -2615,7 +2615,7 @@ public void testSlowDownIncreasesIntervalAndSucceeds() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-S", auth.getToken()); + 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 @@ -2642,7 +2642,7 @@ public void testStalledResponseBodyAbortsWithinTimeout() throws Exception { .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - auth.getToken(); + auth.signIn(); Assert.fail("expected the stalled body read to abort"); } catch (OidcAuthException e) { long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; @@ -2669,7 +2669,7 @@ public void testTimesOutWhenCodeExpires() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a timeout"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); @@ -2693,7 +2693,7 @@ public void testTokenAlongsideOauthErrorIsRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + 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()); @@ -2718,9 +2718,9 @@ public void testTokenCachedAcrossCalls() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-C", auth.getToken()); - Assert.assertEquals("ACCESS-C", auth.getToken()); - Assert.assertEquals("ACCESS-C", auth.getToken()); + 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()); } @@ -2742,7 +2742,7 @@ public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertFalse("the token must not leak into the message: " + e.getMessage(), @@ -2778,7 +2778,7 @@ public void testTokenResponseExpiresInIsClamped() throws Exception { .allowInsecureTransport(true) .build()) { long before = System.currentTimeMillis(); - Assert.assertEquals("ACCESS-OK", auth.getToken()); + Assert.assertEquals("ACCESS-OK", auth.signIn()); long after = System.currentTimeMillis(); Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get()); @@ -2790,9 +2790,9 @@ public void testTokenResponseExpiresInIsClamped() throws Exception { 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 getToken() re-runs the device flow + // once the clamped token is past expiry, with no refresh token signIn() re-runs the device flow expireCachedToken(auth); - Assert.assertEquals("ACCESS-OK", auth.getToken()); + Assert.assertEquals("ACCESS-OK", auth.signIn()); Assert.assertEquals("expired clamped token forces a fresh sign-in", 2, deviceCalls.get()); } }); @@ -2816,7 +2816,7 @@ public void testTokenResponseExpiresInZeroUsesDefaultTtl() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { long before = System.currentTimeMillis(); - Assert.assertEquals("ACCESS-DEF", auth.getToken()); + Assert.assertEquals("ACCESS-DEF", auth.signIn()); long after = System.currentTimeMillis(); Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get()); @@ -2846,7 +2846,7 @@ public void testTokenUnderNonSuccessStatusIsNotAccepted() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a token under a 400 to be rejected, not accepted"); } catch (OidcAuthException e) { Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED")); @@ -2873,7 +2873,7 @@ public void testTokenWithControlCharsRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a token with control characters to be rejected"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); @@ -2901,7 +2901,7 @@ public void testTokenWithNonAsciiCharRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a token with a non-ASCII character to be rejected"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); @@ -2929,7 +2929,7 @@ public void testTransientParseFailureDuringPollingRecovers() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("ACCESS-RECOVERED", auth.getToken()); + Assert.assertEquals("ACCESS-RECOVERED", auth.signIn()); Assert.assertEquals(2, tokenCalls.get()); } }); @@ -2968,7 +2968,7 @@ public void testTruncatedTokenResponseRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not parse")); @@ -2990,7 +2990,7 @@ public void testUnexpectedTokenResponseRejected() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response")); @@ -3002,7 +3002,7 @@ public void testUnexpectedTokenResponseRejected() throws Exception { @Test(timeout = 30_000) public void testUnreachableDeviceEndpointThrowsOidcAuthException() throws Exception { assertMemoryLeak(() -> { - // a connection failure to the device endpoint must surface as OidcAuthException (getToken's + // 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())) { @@ -3015,7 +3015,7 @@ public void testUnreachableDeviceEndpointThrowsOidcAuthException() throws Except .allowInsecureTransport(true) .prompt(noopPrompt()) .build()) { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint")); @@ -3025,7 +3025,7 @@ public void testUnreachableDeviceEndpointThrowsOidcAuthException() throws Except @Test(timeout = 30_000) public void testUseAfterCloseThrowsClearly() { - // calling getToken()/clearCache() after close() must fail with a clear "closed" error rather than + // 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 @@ -3038,8 +3038,8 @@ public void testUseAfterCloseThrowsClearly() { ) { auth.close(); try { - auth.getToken(); - Assert.fail("expected getToken() after close() to be rejected"); + auth.signIn(); + Assert.fail("expected signIn() after close() to be rejected"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); } @@ -3049,7 +3049,7 @@ public void testUseAfterCloseThrowsClearly() { } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); } - // getToken() must reject before resurrecting a native HTTP client, and close() must have freed + // 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)); @@ -3078,7 +3078,7 @@ public void testVerificationUrlAliasesParsed() throws Exception { AtomicReference shown = new AtomicReference<>(); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - Assert.assertEquals("ACCESS-ALIAS", auth.getToken()); + Assert.assertEquals("ACCESS-ALIAS", auth.signIn()); DeviceAuthorizationChallenge challenge = shown.get(); Assert.assertNotNull(challenge); Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri()); @@ -3091,7 +3091,7 @@ public void testVerificationUrlAliasesParsed() throws Exception { 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). getToken() must fail the first call, then re-run 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(); @@ -3110,13 +3110,13 @@ public void testWrongTokenKindDoesNotWedgeCache() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected an OidcAuthException on the first call"); } catch (OidcAuthException e) { Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); } // the unusable grant must NOT be cached as valid: the next call re-runs the flow and succeeds - Assert.assertEquals("ID-2", auth.getToken()); + Assert.assertEquals("ID-2", auth.signIn()); Assert.assertEquals("the interactive flow must run twice (failed first, recovered second)", 2, deviceCalls.get()); } }); @@ -3179,7 +3179,7 @@ private static OidcDeviceAuth.DiscoveryOptions insecure() { @Test(timeout = 30_000) public void testControlCharInUnusedTokenKindDoesNotAbortGrant() throws Exception { assertMemoryLeak(() -> { - // groupsInToken=false, so getToken() serves and sends only the access_token; the id_token is + // 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) -> { @@ -3197,7 +3197,7 @@ public void testControlCharInUnusedTokenKindDoesNotAbortGrant() throws Exception }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - Assert.assertEquals("CLEAN-ACCESS", auth.getToken()); + Assert.assertEquals("CLEAN-ACCESS", auth.signIn()); } }); } @@ -3228,7 +3228,7 @@ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { try { - auth.getToken(); + auth.signIn(); Assert.fail("expected a malformed 1-digit status to be rejected, not accepted as success"); } catch (OidcAuthException e) { String msg = e.getMessage(); @@ -3240,7 +3240,7 @@ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception { } // Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next - // getToken()/getTokenSilently() takes the silent-refresh (or interactive re-sign-in) path. Reflection + // 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. private static void expireCachedToken(OidcDeviceAuth auth) throws Exception { 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 index de05bd260..9a07c7495 100644 --- 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 @@ -39,7 +39,7 @@ * 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::getTokenSilently)} - be wired before the interactive sign-in + * {@code .httpTokenProvider(auth::getToken)} - be wired before the interactive sign-in * has completed. *

    * An explicit {@code protocol_version} keeps {@link Sender.LineSenderBuilder#build()} from probing @@ -52,7 +52,7 @@ public class LineHttpSenderTokenProviderTest { @Test public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception { assertMemoryLeak(() -> { - // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getTokenSilently + // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getToken AtomicBoolean signedIn = new AtomicBoolean(false); HttpTokenProvider provider = () -> { if (!signedIn.get()) { 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 d4ad155e5..1d3c92beb 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..96d899917 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java @@ -0,0 +1,122 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import org.junit.Assert; +import org.junit.Test; + +/** + * 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. None of these need a live socket -- + * {@link QwpQueryClient#getAuthorizationHeaderForTest()} resolves the header the + * same way a real upgrade does. The post-connect guard for the setter lives in + * {@link QwpQueryClientPostConnectGuardTest}. + */ +public class QwpQueryClientTokenProviderTest { + + @Test + public void testProviderConflictsWithBasicAuth() { + 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() { + 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 testProviderNullRejected() { + 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() { + 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() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(() -> "abc123")) { + Assert.assertEquals("Bearer abc123", c.getAuthorizationHeaderForTest()); + } + } + + @Test + public void testProviderTokenValidated() { + 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() { + 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 + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java new file mode 100644 index 000000000..e8ea79e01 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java @@ -0,0 +1,70 @@ +package io.questdb.client.test.example; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.OidcDeviceAuth; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +public class OIDCAuthExample { + public static void main(String[] args) { + + // Discover the client id, scope and endpoints from the QuestDB server's /settings: + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "http://localhost:9000", + new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true) + )) { + // one-time interactive sign-in; caches token + refresh token + auth.signIn(); + + // ingress - ILP over HTTP + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("localhost:9000") + .httpTokenProvider(auth::getToken) + .build()) { + sender.table("abcde") + .longColumn("c0", 25) + .atNow(); + } + + // ingress - QWP + try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:9000") + .httpTokenProvider(auth::getToken) + .build()) { + sender.table("abcde") + .longColumn("c0", 28) + .atNow(); + } + + // egress - QWP + CollectingHandler handler = new CollectingHandler(); + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(auth::getToken)) { + client.connect(); + client.execute("SELECT c0, ts FROM abcde", handler); + } + } + } + + static final class CollectingHandler implements QwpColumnBatchHandler { + public void onBatch(QwpColumnBatch batch) { + batch.forEachRow(row -> { + long c0 = row.getLongValue(0); + // QuestDB TIMESTAMP columns arrive as microseconds since the Unix epoch + Instant ts = Instant.EPOCH.plus(row.getLongValue(1), ChronoUnit.MICROS); + System.out.printf("%d %s%n", c0, ts); + }); + } + + public void onEnd(long totalRows) { + } + + public void onError(byte status, String message) { + System.err.println("query failed: status=" + status + " msg=" + message); + } + } +} diff --git a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java index 2ca102b95..a1cfefb41 100644 --- a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -22,14 +22,14 @@ public static void main(String[] args) { // import io.questdb.client.cutlass.auth.DeviceCodePrompt; // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT)) try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { - auth.getToken(); // sign in once (prompts on first use, then caches and refreshes silently) + auth.signIn(); // sign in once (prompts on first use, then caches and refreshes silently) // 1. Ingest with the QuestDB client over ILP-over-HTTP, presenting the token as a Bearer. // Pass a provider, not the fixed token, so a long-lived sender follows silent refreshes. try (Sender sender = Sender.builder(Sender.Transport.HTTP) .address("questdb.example.com:9000") .enableTls() - .httpTokenProvider(auth::getTokenSilently) + .httpTokenProvider(auth::getToken) .build()) { sender.table("trades") .symbol("symbol", "ETH-USD") From 8d38d4f74dce7f3e9e89746428ab3d8ca629d5d4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 21:37:59 +0100 Subject: [PATCH 055/192] OIDC device-flow review follow-ups Follow-ups from the device-flow review: - README: the OIDC quick-start still called the pre-rename API (getToken() as the interactive sign-in, auth::getTokenSilently). Use signIn() for the one-time sign-in and auth::getToken for the token provider, matching the renamed methods. - OidcDeviceAuth: pre-encode the invariant form params (client_id, scope, audience) once in the constructor and the device_code once in pollForToken, instead of re-running URLEncoder on every poll (~once/5s through a sign-in) and every silent refresh. Mirrors the existing GRANT_TYPE_*_ENCODED constants; the wire output is unchanged. Add appendEncodedParam for the pre-encoded values and keep appendParam for the dynamic refresh_token. - Reorder getToken() before signIn() and rename the lagging testGetTokenSilently* / testConcurrentGetToken* methods to match the renamed API. - QwpQueryClientTokenProviderTest: cover the real connect path, not just the test hook - a throwing provider fails the connection attempt, the pulled token reaches the actual upgrade Authorization header, and a null, empty or blank provider return is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 12 +- .../client/cutlass/auth/OidcDeviceAuth.java | 127 +++++++++--------- .../test/cutlass/auth/OidcDeviceAuthTest.java | 8 +- .../QwpQueryClientTokenProviderTest.java | 118 +++++++++++++++- 4 files changed, 192 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 951ab6a96..2e03f8cd4 100644 --- a/README.md +++ b/README.md @@ -168,15 +168,15 @@ import io.questdb.client.cutlass.auth.OidcDeviceAuth; // Discover the client id, scope and endpoints from the QuestDB server's /settings: try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { - auth.getToken(); // sign in once: prompts on first use, then caches and refreshes + auth.signIn(); // sign in once: prompts on first use, then caches and refreshes // Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each - // request, so a long-lived sender keeps working as the token rotates. getTokenSilently() refreshes + // request, so a long-lived sender keeps working as the token rotates. getToken() refreshes // silently and never prompts on the flush path. try (Sender sender = Sender.builder(Sender.Transport.HTTP) .address("questdb.example.com:9000") .enableTls() - .httpTokenProvider(auth::getTokenSilently) + .httpTokenProvider(auth::getToken) .build()) { sender.table("trades") .symbol("symbol", "ETH-USD") @@ -186,7 +186,7 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.c } ``` -Prefer `httpTokenProvider(auth::getTokenSilently)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. +Prefer `httpTokenProvider(auth::getToken)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: @@ -195,7 +195,7 @@ By default the prompt prints the verification URL and code to `System.out` **and try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( "https://questdb.example.com:9000", new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) { - auth.getToken(); + auth.signIn(); } ``` @@ -222,7 +222,7 @@ Discovery via `fromQuestDB(...)` reads the OIDC client id, scope, audience and e try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( "https://questdb.example.com:9000", new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) { - auth.getToken(); + auth.signIn(); } ``` 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 index 6e76366ad..476acde0d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -45,8 +45,8 @@ import io.questdb.client.std.str.DirectUtf8Sequence; import io.questdb.client.std.str.StringSink; -import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.concurrent.locks.ReentrantLock; /** @@ -146,8 +146,8 @@ public class OidcDeviceAuth implements QuietCloseable { 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 audience; - private final String clientId; + private final String audienceEncoded; + private final String clientIdEncoded; private final DeviceAuthorizationResponseParser deviceAuthParser = new DeviceAuthorizationResponseParser(); private final Endpoint deviceAuthorizationEndpoint; private final StringSink formSink = new StringSink(); @@ -158,7 +158,7 @@ public class OidcDeviceAuth implements QuietCloseable { private final ReentrantLock lock = new ReentrantLock(); private final DeviceCodePrompt prompt; private final StringSink responseStatus = new StringSink(); - private final String scope; + private final String scopeEncoded; private final ClientTlsConfiguration tlsConfig; private final Endpoint tokenEndpoint; private final TokenResponseParser tokenParser = new TokenResponseParser(); @@ -175,11 +175,16 @@ public class OidcDeviceAuth implements QuietCloseable { private long tokenTtlMillis; private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { - this.clientId = builder.clientId; + 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); this.deviceAuthorizationEndpoint = Endpoint.parse(builder.deviceAuthorizationEndpoint); this.tokenEndpoint = Endpoint.parse(builder.tokenEndpoint); - this.scope = builder.scope; - this.audience = builder.audience; + 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; this.prompt = builder.prompt; @@ -406,39 +411,6 @@ public String getAuthorizationHeaderValue() { return "Bearer " + signIn(); } - /** - * 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, or the identity provider - * does not return the expected token - */ - public String signIn() { - lock.lock(); - try { - throwIfClosed(); - // 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 re-run the flow 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) { - if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { - return cachedToken; - } - if (refreshToken != null && tryRefresh()) { - return selectToken(); - } - } - runDeviceFlow(); - return selectToken(); - } finally { - lock.unlock(); - } - } - /** * 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 @@ -485,6 +457,39 @@ public String getToken() { } } + /** + * 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, or the identity provider + * does not return the expected token + */ + public String signIn() { + lock.lock(); + try { + throwIfClosed(); + // 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 re-run the flow 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) { + if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } + } + runDeviceFlow(); + return selectToken(); + } finally { + lock.unlock(); + } + } + private static String appendSettingsPath(String basePath) { String trimmed = basePath; while (trimmed.length() > 1 && trimmed.charAt(trimmed.length() - 1) == '/') { @@ -887,13 +892,8 @@ private static boolean settingsChannelIsPlaintext(Endpoint server) { } 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"); - } + // the Charset overload is Java 10; the client targets Java 8, so use the String-charset form + return URLEncoder.encode(value, StandardCharsets.UTF_8); } private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint deviceAuthorizationEndpoint, Endpoint issuer) { @@ -953,6 +953,10 @@ private static String wellKnownUrl(String issuer) { return trimmed + WELL_KNOWN_OPENID_CONFIGURATION_PATH; } + 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)); } @@ -1001,6 +1005,9 @@ private boolean isHttpStatusTransient() { } 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) { @@ -1011,7 +1018,7 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS throw new OidcAuthException("timed out waiting for authorization, the device code expired; please retry"); } try { - int result = pollOnce(deviceCode); + int result = pollOnce(deviceCodeEncoded); if (result == POLL_SUCCESS) { return; } @@ -1040,11 +1047,11 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS } } - private int pollOnce(String deviceCode) { + private int pollOnce(String deviceCodeEncoded) { formSink.clear(); formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_DEVICE_CODE_ENCODED); - appendParam(formSink, "device_code", deviceCode); - appendParam(formSink, "client_id", clientId); + 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 @@ -1161,10 +1168,10 @@ private void readResponse(HttpClient client, HttpClient.ResponseHeaders response private void runDeviceFlow() { formSink.clear(); - formSink.putAscii("client_id=").putAscii(urlEncode(clientId)); - appendParam(formSink, "scope", scope); - if (audience != null) { - appendParam(formSink, "audience", audience); + formSink.putAscii("client_id=").putAscii(clientIdEncoded); + appendEncodedParam(formSink, "scope", scopeEncoded); + if (audienceEncoded != null) { + appendEncodedParam(formSink, "audience", audienceEncoded); } deviceAuthParser.clear(); @@ -1281,12 +1288,12 @@ private boolean tryRefresh() { formSink.clear(); formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED); appendParam(formSink, "refresh_token", refreshToken); - appendParam(formSink, "client_id", clientId); - if (scope != null) { - appendParam(formSink, "scope", scope); + appendEncodedParam(formSink, "client_id", clientIdEncoded); + if (scopeEncoded != null) { + appendEncodedParam(formSink, "scope", scopeEncoded); } - if (audience != null) { - appendParam(formSink, "audience", audience); + if (audienceEncoded != null) { + appendEncodedParam(formSink, "audience", audienceEncoded); } tokenParser.clear(); 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 index 1973bd72b..56854c304 100644 --- 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 @@ -564,7 +564,7 @@ public void testCloseCancelsInFlightSignIn() throws Exception { } @Test(timeout = 30_000) - public void testConcurrentGetTokenStartsSingleSignIn() throws Exception { + 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 @@ -1388,7 +1388,7 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except } @Test(timeout = 30_000) - public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exception { + 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 @@ -1436,7 +1436,7 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc } @Test(timeout = 30_000) - public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Exception { + public void testGetTokenDoesNotBlockBehindSilentRefresh() throws Exception { assertMemoryLeak(() -> { // the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not // just an interactive sign-in: getToken() must fail fast rather than queue behind it. The @@ -1503,7 +1503,7 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti } @Test(timeout = 30_000) - public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception { + 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 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 index 96d899917..b542f914e 100644 --- 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 @@ -24,18 +24,31 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpQueryClient; import org.junit.Assert; import org.junit.Test; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * 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. None of these need a live socket -- - * {@link QwpQueryClient#getAuthorizationHeaderForTest()} resolves the header the - * same way a real upgrade does. The post-connect guard for the setter lives in + * 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}. */ public class QwpQueryClientTokenProviderTest { @@ -64,6 +77,24 @@ public void testProviderConflictsWithBearerToken() { } } + @Test + public void testProviderNullOrBlankReturnRejected() { + // 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() { try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)) { @@ -95,6 +126,65 @@ public void testProviderSynthesizesBearerHeader() { } } + @Test(timeout = 15_000) + public void testProviderTokenSentOnRealUpgrade() throws Exception { + // drive the REAL connect path (runUpgradeWithTimeout -> resolveAuthorizationHeader), 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 already sent. + List authHeaders = Collections.synchronizedList(new ArrayList<>()); + ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + int port = listener.getLocalPort(); + byte[] respBytes = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + Thread serverThread = new Thread(() -> { + while (!listener.isClosed()) { + try { + Socket s = listener.accept(); + Thread handler = new Thread(() -> { + try (Socket sock = s) { + byte[] buf = new byte[8192]; + int n = sock.getInputStream().read(buf); + if (n < 0) { + return; + } + String request = new String(buf, 0, n, StandardCharsets.US_ASCII); + for (String line : request.split("\r\n")) { + if (line.regionMatches(true, 0, "Authorization:", 0, "Authorization:".length())) { + authHeaders.add(line.substring("Authorization:".length()).trim()); + } + } + OutputStream os = sock.getOutputStream(); + os.write(respBytes); + os.flush(); + } catch (Exception ignored) { + } + }, "qwp-token-upgrade-handler"); + handler.setDaemon(true); + handler.start(); + } catch (Exception ignored) { + return; + } + } + }, "qwp-token-upgrade-server"); + serverThread.setDaemon(true); + serverThread.start(); + + try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=127.0.0.1:" + 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 above + } + } finally { + listener.close(); + serverThread.join(500); + } + 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() { try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) @@ -119,4 +209,26 @@ public void testSettingBearerTokenThenProviderConflicts() { } } } + + @Test(timeout = 10_000) + public void testThrowingProviderFailsConnect() throws Exception { + // a provider that throws must fail the connection attempt on the REAL connect path: + // resolveAuthorizationHeader runs inside runUpgradeWithTimeout, before the socket connect, so the + // throw aborts the upgrade; connect() exhausts the single endpoint and surfaces the provider failure + 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) { + Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("provider down")); + } + } + } } From 272d7042ffe727ae8e2f07a53e964070f7f052bb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 22:10:11 +0100 Subject: [PATCH 056/192] Fix Java 8 build: use URLEncoder String charset The follow-up that pre-encodes the OIDC form params changed urlEncode() to URLEncoder.encode(value, StandardCharsets.UTF_8). That Charset overload is @since 10, so the source-of-truth JDK 8 build fails to compile: "incompatible types: Charset cannot be converted to String" at OidcDeviceAuth.java:896. Revert urlEncode() to the Java 8 String-charset form, URLEncoder.encode(value, "UTF-8") with the UnsupportedEncodingException catch, and drop the now-unused StandardCharsets import. The constructor-time pre-encoding of clientId/scope/audience/device_code is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../questdb/client/cutlass/auth/OidcDeviceAuth.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index 476acde0d..4a452858f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -45,8 +45,8 @@ import io.questdb.client.std.str.DirectUtf8Sequence; import io.questdb.client.std.str.StringSink; +import java.io.UnsupportedEncodingException; import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.concurrent.locks.ReentrantLock; /** @@ -892,8 +892,13 @@ private static boolean settingsChannelIsPlaintext(Endpoint server) { } private static String urlEncode(String value) { - // the Charset overload is Java 10; the client targets Java 8, so use the String-charset form - return URLEncoder.encode(value, StandardCharsets.UTF_8); + 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) { From 1b7fecd9c204217175ab70bf947d7a2af9c7dfa6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 25 Jun 2026 22:27:06 +0100 Subject: [PATCH 057/192] Fail fast on QWP token-provider failures Address three review follow-ups in the OIDC device-flow client. QwpQueryClient resolved the bearer token inside the per-endpoint upgrade, so a token-provider failure (not signed in, a failed silent refresh, a rejected token) was caught as a per-endpoint transport error and reported as "all QWP endpoints unreachable", and the provider was queried once per endpoint. Resolve the header once before the endpoint walk in connect() and reconnectViaTracker() and thread it through connectToEndpoint/runUpgradeWithTimeout, so a provider failure - which is cluster-wide - propagates directly with the provider's own message and the provider is queried once per connect/reconnect. Strengthen testThrowingProviderFailsConnect to assert the provider's own exception surfaces, not a wrapped "unreachable" error. Validate Builder.httpTimeoutMillis: a non-positive value gave an already-expired read deadline and an unbounded recv(int), so reject it like Sender.Builder already does. Add a test. Drop the always-true scopeEncoded null check in tryRefresh: build() defaults scope to DEFAULT_SCOPE, so append it unconditionally like runDeviceFlow(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 7 ++-- .../cutlass/qwp/client/QwpQueryClient.java | 37 +++++++++++++------ .../test/cutlass/auth/OidcDeviceAuthTest.java | 14 +++++++ .../QwpQueryClientTokenProviderTest.java | 14 ++++--- 4 files changed, 52 insertions(+), 20 deletions(-) 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 index 4a452858f..1a3a8a9ba 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1294,9 +1294,7 @@ private boolean tryRefresh() { formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED); appendParam(formSink, "refresh_token", refreshToken); appendEncodedParam(formSink, "client_id", clientIdEncoded); - if (scopeEncoded != null) { - appendEncodedParam(formSink, "scope", scopeEncoded); - } + appendEncodedParam(formSink, "scope", scopeEncoded); if (audienceEncoded != null) { appendEncodedParam(formSink, "audience", audienceEncoded); } @@ -1421,6 +1419,9 @@ public Builder groupsInToken(boolean groupsInToken) { } public Builder httpTimeoutMillis(int httpTimeoutMillis) { + if (httpTimeoutMillis <= 0) { + throw new OidcAuthException("httpTimeoutMillis must be positive"); + } this.httpTimeoutMillis = httpTimeoutMillis; return this; } 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 f5f6d9a49..fac498e76 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 @@ -684,6 +684,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()) { @@ -702,6 +707,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) { @@ -709,7 +720,7 @@ public synchronized void connect() { } Endpoint ep = endpoints.get(i); try { - connectToEndpoint(ep); + connectToEndpoint(ep, authHeader); } catch (QwpAuthFailedException ae) { cleanupFailedConnect(); throw ae; @@ -1401,7 +1412,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)); @@ -1412,7 +1423,7 @@ private void connectToEndpoint(Endpoint ep) { webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId()); webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader()); webSocketClient.setQwpMaxBatchRows(maxBatchRows); - runUpgradeWithTimeout(ep); + runUpgradeWithTimeout(ep, authHeader); negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); negotiatedZstdLevel = webSocketClient.getServerNegotiatedZstdLevel(); @@ -1730,6 +1741,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) { @@ -1742,7 +1757,7 @@ private void reconnectViaTracker() { } Endpoint ep = endpoints.get(i); try { - connectToEndpoint(ep); + connectToEndpoint(ep, authHeader); } catch (QwpAuthFailedException ae) { cleanupFailedConnect(); throw ae; @@ -1788,12 +1803,11 @@ private void reconnectViaTracker() { } private String resolveAuthorizationHeader() { - // With a token provider, re-query it at each upgrade 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) propagates and fails this connection - // attempt, matching the QWP ingress sender. + // 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) + // propagates out of connect()/reconnect with its own message, matching the QWP ingress sender. if (tokenProvider != null) { CharSequence token = tokenProvider.getToken(); HttpTokenProvider.validateToken(token); @@ -1802,9 +1816,8 @@ private String resolveAuthorizationHeader() { return authorizationHeader; } - private void runUpgradeWithTimeout(Endpoint ep) { + private void runUpgradeWithTimeout(Endpoint ep, String authHeader) { int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); - String authHeader = resolveAuthorizationHeader(); try { webSocketClient.connect(ep.host, ep.port); webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader); 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 index 56854c304..d01c45cdf 100644 --- 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 @@ -266,6 +266,20 @@ public void testBuilderRejectsMissingRequiredOptions() { } } + @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 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 index b542f914e..be3ea8bfd 100644 --- 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 @@ -128,9 +128,9 @@ public void testProviderSynthesizesBearerHeader() { @Test(timeout = 15_000) public void testProviderTokenSentOnRealUpgrade() throws Exception { - // drive the REAL connect path (runUpgradeWithTimeout -> resolveAuthorizationHeader), 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 already sent. + // 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. List authHeaders = Collections.synchronizedList(new ArrayList<>()); ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); int port = listener.getLocalPort(); @@ -213,8 +213,8 @@ public void testSettingBearerTokenThenProviderConflicts() { @Test(timeout = 10_000) public void testThrowingProviderFailsConnect() throws Exception { // a provider that throws must fail the connection attempt on the REAL connect path: - // resolveAuthorizationHeader runs inside runUpgradeWithTimeout, before the socket connect, so the - // throw aborts the upgrade; connect() exhausts the single endpoint and surfaces the provider failure + // 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( @@ -227,7 +227,11 @@ public void testThrowingProviderFailsConnect() throws Exception { 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")); } } } From a4e928fea5106cd748df4db624d4ec5d62842261 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 29 Jun 2026 18:03:50 +0100 Subject: [PATCH 058/192] opt-in token persistence --- README.md | 20 + .../client/cutlass/auth/FileTokenStore.java | 667 ++++++++++++++++++ .../client/cutlass/auth/OidcDeviceAuth.java | 223 +++++- .../client/cutlass/auth/PersistedToken.java | 71 ++ .../client/cutlass/auth/TokenStore.java | 103 +++ .../client/cutlass/auth/TokenStoreKey.java | 156 ++++ .../test/cutlass/auth/FileTokenStoreTest.java | 452 ++++++++++++ .../auth/OidcDeviceAuthPersistenceTest.java | 539 ++++++++++++++ design/oidc-token-persistence.md | 459 ++++++++++++ .../example/sender/OidcDeviceFlowExample.java | 4 + 10 files changed, 2682 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/PersistedToken.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java create mode 100644 design/oidc-token-persistence.md diff --git a/README.md b/README.md index 2e03f8cd4..e06a7f677 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,26 @@ The identity provider's device authorization and token endpoints must use `https `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 identity 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, so tokens for different servers or identities never collide. 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. + +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 prints a one-line warning to `System.err` the first time it cannot enforce them. 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 — a tampered, corrupt, oversized, or identity-mismatched entry is ignored (the client falls back to a refresh or an interactive sign-in), and a token carrying control or non-ASCII characters is never placed on the wire. + +`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. + ### Explicit Timestamps ```java 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..28b91efc3 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -0,0 +1,667 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 java.io.IOException; +import java.lang.management.ManagementFactory; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +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.Set; + +/** + * The default {@link TokenStore}: one plaintext JSON file per identity 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 identities 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. + *

    + * 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. + *

    + * 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, so the worst-case hold is ~480s and this 10-minute window stays safely above it + 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------"); + private static final FileAttribute> FILE_ATTRS = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); + 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; + // 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; + 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 once + private static volatile boolean warnedNoPosixPerms; + private final Path directory; + private final long lockAcquireBudgetMillis; + 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 + * @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: one + * refresh under the lock 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). Set it below that and a peer can steal a + * live holder's 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 (safely above the ~480s worst case). + */ + 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"); + } + // 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.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) { + try { + Files.deleteIfExists(tokenFile(key)); + } catch (IOException e) { + throw new OidcAuthException(e).put("could not remove the OIDC token store file"); + } + } + + @Override + public boolean inLock(TokenStoreKey key, CriticalSection action) { + Path lock = null; + boolean held; + try { + ensureDirectory(); + lock = lockFile(key); + held = acquireLock(lock); + } catch (IOException e) { + // could not prepare the lock directory or file; run without the 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. + held = false; + } + try { + return action.run(); + } finally { + if (held) { + try { + Files.deleteIfExists(lock); + } catch (IOException ignore) { + // best-effort release; a leftover lock goes stale and the next acquirer steals it + } + } + } + } + + @Override + public PersistedToken load(TokenStoreKey key) { + Path file = tokenFile(key); + byte[] bytes; + try { + if (!Files.exists(file)) { + return null; + } + long size = Files.size(file); + if (size <= 0 || size > MAX_FILE_BYTES) { + // an empty or implausibly large file is not a usable entry; ignore it rather than read it + return null; + } + bytes = Files.readAllBytes(file); + } catch (NoSuchFileException e) { + return null; + } catch (IOException e) { + throw new OidcAuthException(e).put("could not read the OIDC token store file"); + } + return parseAndVerify(key, bytes); + } + + @Override + public void save(TokenStoreKey key, PersistedToken token) { + byte[] content = serialize(key, token); + try { + ensureDirectory(); + Path target = tokenFile(key); + Path tmp = createTempFile(key.hash()); + boolean moved = false; + try { + writeAndFlush(tmp, content); + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + // a rare filesystem without atomic rename; a plain replace still beats a partial write + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + moved = true; + } finally { + if (!moved) { + Files.deleteIfExists(tmp); + } + } + } catch (IOException e) { + throw new OidcAuthException(e).put("could not persist the OIDC token to the token store"); + } + } + + private static void createLockFile(Path lock) throws IOException { + try { + Files.createFile(lock, FILE_ATTRS); + } catch (UnsupportedOperationException e) { + warnNoPosixPermsOnce(); + Files.createFile(lock); + } + } + + 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 long parseLongOrZero(CharSequence value) { + try { + return Numbers.parseLong(value); + } catch (NumericException e) { + return 0; + } + } + + 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)) { + for (int i = 0; i < bytes.length; i++) { + // putAny accepts any byte; put(byte) is asserted for non-ASCII bytes only + mem.putAny(bytes[i]); + } + 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 + if (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 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); + sink.put(value); + } + + 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); + } + + private static void restrictToOwner(Path directory) { + // best-effort: the at-rest protection of the plaintext token files is exactly these owner-only + // directory permissions, so tighten a pre-existing directory rather than trust whatever it had. On a + // non-POSIX filesystem (Windows) this is unsupported and falls back to the directory's existing ACL + // (owner-only hardening there, via AclFileAttributeView, is a separate follow-up) + try { + Files.setPosixFilePermissions(directory, DIR_PERMS); + } catch (UnsupportedOperationException e) { + // non-POSIX FS (e.g. Windows): cannot enforce owner-only perms; keep the inherited ACL + warnNoPosixPermsOnce(); + } catch (IOException ignore) { + // the directory is not ours to chmod: keep the existing permissions + } + } + + 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) { + return; + } + warnedNoPosixPerms = true; + System.err.println("questdb client: 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 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 writeLockHolder(Path lock) { + // record the holder (pid@host) and a creation timestamp for debugging only; never fail acquisition + // over it. Staleness is judged by the file's mtime, not by parsing this content + try { + String holder = ManagementFactory.getRuntimeMXBean().getName() // typically pid@host + + ' ' + System.currentTimeMillis(); + Files.write(lock, holder.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (Exception ignore) { + // best-effort metadata only + } + } + + private boolean acquireLock(Path lock) { + final long deadline = System.currentTimeMillis() + lockAcquireBudgetMillis; + while (true) { + try { + createLockFile(lock); + writeLockHolder(lock); + return true; + } catch (FileAlreadyExistsException e) { + if (isStale(lock)) { + // a crashed holder left the lock behind; steal it + try { + Files.deleteIfExists(lock); + } catch (IOException ignore) { + // another acquirer may have removed it; the next createLockFile settles the race + } + // 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 + } + if (System.currentTimeMillis() >= deadline) { + return false; // give up and run without the lock rather than stall a sign-in + } + Os.sleep(LOCK_POLL_SLICE_MILLIS); + } catch (IOException e) { + return false; // unexpected IO; degrade to no lock + } + } + } + + 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"); + } + } + + private void ensureDirectory() throws IOException { + if (Files.isDirectory(directory)) { + // re-assert owner-only permissions on a pre-existing directory: createDirectories applies + // DIR_ATTRS only when it creates the directory, so one left world/group-accessible by another + // tool, a permissive umask, or a hostile local pre-create would otherwise expose the token files + restrictToOwner(directory); + return; + } + try { + Files.createDirectories(directory, DIR_ATTRS); + } catch (UnsupportedOperationException e) { + warnNoPosixPermsOnce(); + Files.createDirectories(directory); + } + } + + private boolean isStale(Path lock) { + try { + FileTime modified = Files.getLastModifiedTime(lock); + return System.currentTimeMillis() - modified.toMillis() > lockStaleMillis; + } catch (IOException e) { + return false; // cannot determine the age; do not steal + } + } + + private Path lockFile(TokenStoreKey key) { + return directory.resolve(key.hash() + ".lock"); + } + + private Path tokenFile(TokenStoreKey key) { + return directory.resolve(key.hash() + ".json"); + } + + 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; + int version; + private int depth; + private int field = FIELD_NONE; + + @Override + public void onEvent(int code, CharSequence tag, int position) { + switch (code) { + 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: + version = (int) 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, which is harmless - a + // bogus token simply fails its fingerprint/char check and the entry falls back. + sink.clear(); + sink.put(tag); + } + } +} 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 index 1a3a8a9ba..df27a54b3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -47,6 +47,8 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.Locale; +import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; /** @@ -94,7 +96,9 @@ * 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 and does not survive a process restart. + * 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"; @@ -132,6 +136,11 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 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) safely + // shorter than that store's lock-staleness window, so a slow refresh's live lock is not stolen by a peer + 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; @@ -159,16 +168,20 @@ public class OidcDeviceAuth implements QuietCloseable { 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; private JsonLexer jsonLexer; + private String lastPersistedRefreshToken; private HttpClient plainClient; private String refreshToken; + private boolean storeLoadAttempted; 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 @@ -189,6 +202,18 @@ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { this.httpTimeoutMillis = builder.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: an Endpoint.parse above can throw on a malformed url, 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); @@ -352,6 +377,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt .allowInsecureTransport(allowInsecureTransport) .tlsConfig(tlsConfig) .prompt(options.prompt) + .tokenStore(options.tokenStore) .build(); } @@ -367,6 +393,16 @@ public void clearCache() { refreshToken = null; expiresAtMillis = 0; tokenTtlMillis = 0; + lastPersistedRefreshToken = null; + 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(); } @@ -422,8 +458,11 @@ public String getAuthorizationHeaderValue() { * 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 is not, however, instantaneous - when the cached * token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by - * {@link Builder#httpTimeoutMillis(int)} (30s by default). That is the "quick silent refresh" the - * {@code HttpTokenProvider} contract permits on the flush path, not an unbounded interactive wait. + * {@link Builder#httpTimeoutMillis(int)} (30s by default); when a {@link TokenStore} coordinates the + * refresh across processes it may first wait briefly to acquire the store's per-identity lock (a few + * seconds at most for {@link FileTokenStore}, then it proceeds without the lock) before that round-trip. + * 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 @@ -441,12 +480,13 @@ public String getToken() { } try { throwIfClosed(); + maybeLoadFromStore(); final String cachedToken = groupsInToken ? idToken : accessToken; if (cachedToken != null) { if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { return cachedToken; } - if (refreshToken != null && tryRefresh()) { + if (refreshToken != null && tryRefreshCoordinated()) { return selectToken(); } throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call signIn() to sign in again"); @@ -470,6 +510,7 @@ public String signIn() { lock.lock(); try { throwIfClosed(); + 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 re-run the flow rather than report the unusable grant as @@ -479,7 +520,7 @@ public String signIn() { if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { return cachedToken; } - if (refreshToken != null && tryRefresh()) { + if (refreshToken != null && tryRefreshCoordinated()) { return selectToken(); } } @@ -505,6 +546,13 @@ private static int boundedSeconds(int value, int defaultValue, int maxValue) { 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 @@ -649,6 +697,16 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura } } + 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'; @@ -940,13 +998,10 @@ private static void validateTokenChars(CharSequence token, String tokenName) { // 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. - for (int i = 0, n = token.length(); i < n; i++) { - char c = token.charAt(i); - if (c < 0x20 || c > 0x7e) { - 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"); - } + 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"); } } @@ -958,6 +1013,32 @@ private static String wellKnownUrl(String issuer) { return trimmed + WELL_KNOWN_OPENID_CONFIGURATION_PATH; } + private boolean adopt(PersistedToken token) { + if (token == null) { + return false; + } + // the file is attacker-writable, so treat the served token (the one getToken() puts verbatim into an + // Authorization header or a PG-wire password) as untrusted: reject a control/non-ASCII char - and the + // whole entry - rather than route a tampered credential onto the wire. A null served token is unusable. + String servedToken = groupsInToken ? token.getIdToken() : token.getAccessToken(); + if (servedToken == null || !hasOnlyTokenChars(servedToken)) { + return false; + } + accessToken = token.getAccessToken(); + idToken = token.getIdToken(); + refreshToken = token.getRefreshToken(); + // 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. Capping (not flooring) the expiry preserves an already-expired + // entry, so a stale access token still falls through to a refresh rather than being served forever. + long maxTokenLifeMillis = MAX_EXPIRES_IN_SECONDS * 1000L; + tokenTtlMillis = Math.max(0L, Math.min(token.getTokenTtlMillis(), maxTokenLifeMillis)); + expiresAtMillis = Math.min(token.getExpiresAtMillis(), System.currentTimeMillis() + maxTokenLifeMillis); + // it is already on disk, so a later non-rotating refresh must not rewrite the file + lastPersistedRefreshToken = refreshToken; + return true; + } + private void appendEncodedParam(StringSink sink, String name, String encodedValue) { sink.putAscii('&').putAscii(name).putAscii('=').putAscii(encodedValue); } @@ -1009,6 +1090,44 @@ private boolean isHttpStatusTransient() { return responseStatus.length() == 3 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus)); } + private void maybeLoadFromStore() { + if (tokenStore == null || storeLoadAttempted) { + return; + } + // attempt the disk read once per instance, even if it yields nothing, so a missing or bad file is + // not re-read on every call + storeLoadAttempted = true; + PersistedToken token; + try { + token = tokenStore.load(storeKey); + } catch (RuntimeException e) { + // best-effort: a store read failure must not break sign-in + warnPersistence("load", e); + return; + } + adopt(token); + } + + private void persistIfRotated() { + if (tokenStore == 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) @@ -1171,6 +1290,35 @@ private void readResponse(HttpClient client, HttpClient.ResponseHeaders response } } + 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. If they differ, + // a previous save failed (persistence is best-effort), so the in-memory token is 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. In that case keep the in-memory + // token and refresh with it. + 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); @@ -1258,6 +1406,10 @@ private void sleepBetweenPolls(long millis) { } } + private PersistedToken snapshot() { + return new PersistedToken(accessToken, idToken, refreshToken, expiresAtMillis, tokenTtlMillis); + } + private void storeTokens(TokenResponseParser parser) { // 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 @@ -1281,6 +1433,7 @@ private void storeTokens(TokenResponseParser parser) { int ttlSeconds = boundedSeconds(parser.expiresIn, DEFAULT_TOKEN_TTL_SECONDS, MAX_EXPIRES_IN_SECONDS); tokenTtlMillis = ttlSeconds * 1000L; expiresAtMillis = System.currentTimeMillis() + tokenTtlMillis; + persistIfRotated(); } private void throwIfClosed() { @@ -1330,6 +1483,23 @@ && isHttpStatusSuccess() 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 + return tokenStore.inLock(storeKey, this::refreshUnderLock); + } + + private void warnPersistence(String operation, Throwable cause) { + // best-effort persistence: report to System.err and carry on with the in-memory token. The store + // never puts token bytes in its messages, so this cannot leak the secret. + String detail = cause.getMessage(); + System.err.println("questdb client: OIDC token store " + operation + + " failed; continuing without persistence" + (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. @@ -1346,6 +1516,7 @@ public static final class Builder { private String scope = DEFAULT_SCOPE; private ClientTlsConfiguration tlsConfig; private String tokenEndpoint; + private TokenStore tokenStore; private Builder() { } @@ -1422,6 +1593,12 @@ 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; } @@ -1469,6 +1646,17 @@ 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; + } } /** @@ -1482,6 +1670,7 @@ public static final class DiscoveryOptions { 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 @@ -1531,6 +1720,16 @@ 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 { 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..42394fc24 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.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.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. + * 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 to {@code System.err} 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. + * + * @param key the identity to lock + * @param action the critical section; its boolean result is returned unchanged + * @return whatever {@code action} returned + */ + 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. + * + * @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..82da526f8 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java @@ -0,0 +1,156 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 + ) { + this.clientId = clientId; + this.tokenEndpoint = tokenEndpoint; + this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint; + this.scope = scope; + this.audience = audience; + this.groupsInToken = groupsInToken; + this.hash = computeHash(clientId, tokenEndpoint, deviceAuthorizationEndpoint, scope, audience, groupsInToken); + } + + 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; + } + + 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/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..1f2511ad0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java @@ -0,0 +1,452 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +public class FileTokenStoreTest { + @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 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 testCorruptFileReturnsNull() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + FileTokenStore store = new FileTokenStore(dir); + TokenStoreKey key = sampleKey(); + Files.createDirectories(dir); + Files.write(tokenFile(dir, key), "this is not json {{{".getBytes(StandardCharsets.UTF_8)); + Assert.assertNull(store.load(key)); + }); + } + + @Test + public void testEmptyFileReturnsNull() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + FileTokenStore store = new FileTokenStore(dir); + TokenStoreKey key = sampleKey(); + Files.createDirectories(dir); + Files.write(tokenFile(dir, key), new byte[0]); + Assert.assertNull(store.load(key)); + }); + } + + @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 + Files.createDirectories(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 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(); + Files.createDirectories(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(); + Files.createDirectories(dir); + TokenStoreKey key = sampleKey(); + // two instances over one directory model two processes; a generous acquire budget makes a + // contender wait for the lock 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 + 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(); + TokenStore.CriticalSection section = () -> { + int now = inside.incrementAndGet(); + maxInside.accumulateAndGet(now, Math::max); + if (now > 1) { + overlaps.incrementAndGet(); + } + Os.sleep(200); + inside.decrementAndGet(); + return true; + }; + + Thread tA = new Thread(() -> storeA.inLock(key, section)); + Thread tB = new Thread(() -> storeB.inLock(key, section)); + tA.start(); + tB.start(); + tA.join(); + tB.join(); + + 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 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 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(); + Files.createDirectories(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 testLoadMissingReturnsNull() throws Exception { + assertMemoryLeak(() -> { + FileTokenStore store = new FileTokenStore(storeDir()); + Assert.assertNull(store.load(sampleKey())); + }); + } + + @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 testOversizedFileReturnsNull() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + FileTokenStore store = new FileTokenStore(dir); + TokenStoreKey key = sampleKey(); + Files.createDirectories(dir); + byte[] big = new byte[(1 << 20) + 1]; + java.util.Arrays.fill(big, (byte) ' '); + Files.write(tokenFile(dir, key), big); + Assert.assertNull(store.load(key)); + }); + } + + @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 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 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()); + }); + } + + 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 Path lockFile(Path dir, TokenStoreKey key) { + return dir.resolve(key.hash() + ".lock"); + } + + 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/OidcDeviceAuthPersistenceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java new file mode 100644 index 000000000..892282cd6 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java @@ -0,0 +1,539 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.OidcDeviceAuth; +import io.questdb.client.cutlass.auth.PersistedToken; +import io.questdb.client.cutlass.auth.TokenStore; +import io.questdb.client.cutlass.auth.TokenStoreKey; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +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"; + + static { + System.setProperty("questdb.client.oidc.open.browser", "false"); + } + + @Rule + public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build(); + + @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 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 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 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("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; + PrintStream originalErr = System.err; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setErr(new PrintStream(captured, true, "UTF-8")); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) { + // the save throws, but the sign-in still yields the valid in-memory token + Assert.assertEquals("ACCESS-1", auth.signIn()); + } finally { + System.setErr(originalErr); + } + String err = new String(captured.toByteArray(), StandardCharsets.UTF_8); + Assert.assertTrue("a save failure must warn to System.err: " + err, err.contains("token store save failed")); + }); + } + + @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 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 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 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); + } + }); + } + + 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(); + boolean failSave; + PersistedToken loadReturns; + PersistedToken peerInstallsOnLock; + PersistedToken stored; + + @Override + public void clear(TokenStoreKey key) { + clears.incrementAndGet(); + stored = null; + } + + @Override + public boolean inLock(TokenStoreKey key, CriticalSection action) { + locks.incrementAndGet(); + if (peerInstallsOnLock != null) { + // simulate a peer process refreshing and writing a fresh entry while we hold the lock + stored = peerInstallsOnLock; + peerInstallsOnLock = null; + } + return action.run(); + } + + @Override + public PersistedToken load(TokenStoreKey key) { + loads.incrementAndGet(); + 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/design/oidc-token-persistence.md b/design/oidc-token-persistence.md new file mode 100644 index 000000000..0e46de02c --- /dev/null +++ b/design/oidc-token-persistence.md @@ -0,0 +1,459 @@ +# OIDC device-flow token persistence + +Status: **draft v1**, follow-on to PR #52 (`OidcDeviceAuth`, RFC 8628 device flow). +Targets branch `ia_oidc_device_flow`. + +## Problem + +`OidcDeviceAuth` keeps all token state in memory (`OidcDeviceAuth.java:165-175`): +`accessToken`, `idToken`, `refreshToken`, `expiresAtMillis`, `tokenTtlMillis`. Its +own javadoc says so: *"Token state is in-memory only and does not survive a process +restart."* Every restart of the host app therefore forces the human back through the +interactive device flow (open URL, enter code, authorize), even though a long-lived +**refresh token** that could mint a new access token silently was sitting in memory +seconds earlier. + +Goal: optionally persist the token state so a restarted process resumes from the +refresh token (one silent token-endpoint round-trip) instead of re-prompting — without +weakening any of the trust/secret-handling guarantees PR #52 establishes. + +## Goals + +- **Survive restart without re-prompting.** A process that signed in, then restarted, + obtains a usable token from a persisted refresh token with no human interaction. +- **Opt-in.** Default behaviour is unchanged (in-memory only). Persisting a long-lived + credential to disk is a security trade the caller makes explicitly. +- **Pluggable.** A `TokenStore` SPI so an integrator can back persistence with an OS + keychain / KMS / vault. Ship one default `FileTokenStore` (strict-perms file). +- **Language-neutral on-disk contract.** The Java client is the reference + implementation; the Python client (and any other) will mirror it. The file location, + name, JSON schema, and the multi-writer coordination protocol are therefore a *frozen + cross-language contract*, specified below to the byte, not a Java-internal detail. +- **Correctly scoped.** A persisted entry is keyed by the identity it belongs to + (endpoints + client id + scope + audience + groups-in-token mode); a token is never + served for a different configuration. +- **Crash- and concurrency-safe at the file level.** A torn write or an overlapping + writer never yields a half-read credential. +- **Upholds PR #52's invariants.** Tokens never reach logs or exceptions; a persisted + file is treated as untrusted input and validated before any byte reaches a header. +- **Java 8 floor, zero third-party deps** (`java-questdb-client/CLAUDE.md`): reuse + `JsonLexer`/`StringSink`/`MessageDigest`/`java.nio.file` only. + +## Non-goals (this spec) + +- **Encryption at rest with a built-in key.** A key stored next to the ciphertext is + theatre; a key from an OS secret store needs native code we cannot take as a + dependency. Confidentiality at rest is delegated to (a) filesystem permissions for the + default store and (b) the `TokenStore` SPI for anyone who wants a keychain. Stated as a + residual risk below, not solved here. +- **A new credential surface in connection strings / `QDB_CLIENT_CONF`.** The README + already warns against putting tokens there; persistence is a separate, file-scoped + channel. + +## Background: the two code points that constrain the design + +1. **Single write funnel.** Both the interactive flow (`runDeviceFlow` -> `pollOnce` -> + `storeTokens`) and the silent refresh (`tryRefresh` -> `storeTokens`) commit token + state in exactly one method, `storeTokens(TokenResponseParser)` + (`OidcDeviceAuth.java:1261-1284`). That is the natural — and only — place to persist. + +2. **Refresh is gated behind a non-null cached token.** In `getToken()` + (`OidcDeviceAuth.java:444-454`) and `signIn()` (`OidcDeviceAuth.java:477-486`) the + silent-refresh branch only runs when `cachedToken != null`: + + ```java + final String cachedToken = groupsInToken ? idToken : accessToken; + if (cachedToken != null) { + if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } + // getToken(): throw "expired, can't refresh"; signIn(): fall through to device flow + } + ``` + + Consequence: **restoring only a refresh token does not work with the current logic** — + `signIn()` would skip the refresh and run a fresh device flow; `getToken()` would throw + "no token has been obtained yet". This is the pivotal fact. Two ways out: + + - **(A) Persist the full token blob** (access + id + refresh + `expiresAtMillis` + + `tokenTtlMillis`). On restore `cachedToken != null` holds, so the *existing, audited* + expiry-then-refresh logic runs untouched: a still-valid access token is served with + zero network; an expired one triggers exactly one silent refresh. **No change to the + delicate `signIn`/`getToken`/`tryRefresh` flow.** + - **(B) Persist the refresh token only** and lift the refresh attempt out from behind + the `cachedToken != null` gate in both methods. Smaller on-disk secret footprint, but + it modifies the security-sensitive control flow. + + **Recommendation: (A).** Minimal blast radius on reviewed code, and it makes a quick + restart fully warm (no round-trip at all). The extra on-disk item is a *short-lived* + access token; the long-lived secret (refresh token) is on disk under either option, so + (A) does not change the qualitative risk. (B) is noted as a leaner alternative if we + later decide the access token must never touch disk. + +## API + +New, all in `io.questdb.client.cutlass.auth`: + +```java +public interface TokenStore { + /** Load previously persisted tokens for this identity, or null if none / unreadable. */ + PersistedToken load(TokenStoreKey key); + + /** Persist (atomically replace) the tokens for this identity. Best-effort: an + * implementation reports failure by throwing; the caller treats persistence as + * non-fatal and continues with the in-memory token. */ + void save(TokenStoreKey key, PersistedToken token); + + /** Remove any persisted tokens for this identity. */ + void clear(TokenStoreKey key); +} +``` + +`TokenStoreKey` — the non-secret identity fingerprint, computed by `OidcDeviceAuth` from +its config so the store stays semantics-free: + +```java +public final class TokenStoreKey { + private final String clientId; + private final String tokenEndpoint; // origin + path, canonicalised + private final String deviceAuthorizationEndpoint; + private final String scope; + private final String audience; // may be null + private final boolean groupsInToken; + // getters; equals/hashCode over all fields + // hash(): hex SHA-256 of a canonical join of the fields, for use as a file name +} +``` + +`PersistedToken` — an immutable carrier mirroring the in-memory fields: + +```java +public final class PersistedToken { + private final String accessToken; // nullable + private final String idToken; // nullable + private final String refreshToken; // nullable + private final long expiresAtMillis; // absolute wall-clock; survives restart + private final long tokenTtlMillis; + // ctor + getters only +} +``` + +Builder wiring (default `null` => no persistence, preserving today's behaviour): + +```java +OidcDeviceAuth.builder().clientId(...)....tokenStore(store).build(); +OidcDeviceAuth.fromQuestDB(url, new DiscoveryOptions().tokenStore(store)); +``` + +Convenience: `FileTokenStore.atDefaultLocation()` and `FileTokenStore.at(Path dir)`. + +## `FileTokenStore` (default implementation) + +- **Location.** `${questdb.client.oidc.token.store.dir}` if set, else + `${user.home}/.questdb/oidc-tokens/`. The `questdb.client.oidc.*` system-property + namespace already exists (`questdb.client.oidc.open.browser`), so this matches. +- **One file per identity**, named `.json`. A hashed name avoids + leaking the endpoint/client id/scope through directory listings, and lets several + identities coexist (multiple servers / users on one host). +- **Permissions.** Directory created `rwx------` (0700), file `rw-------` (0600), set + *at creation* via `PosixFilePermissions.asFileAttribute(...)` so there is no + world-readable window. On a non-POSIX FS (`setPosixFilePermissions`/attribute throws + `UnsupportedOperationException`) fall back to the ACL-protected user-profile dir and + log a one-line warning that OS-level perms were not enforced (Windows hardening via + `AclFileAttributeView` is a future item). +- **File format and atomic write** — flat plaintext JSON; the exact schema, file naming, + and write protocol are the frozen cross-language contract in + *On-disk interop contract* below. Parsed with the existing `JsonLexer` + a small + `JsonParser` (same pattern as `TokenResponseParser`); written by hand into a `StringSink` + with `"`/`\`/control-char escaping. +- **Bounded, defensive read.** Cap the file at a sane size (reuse the 1 MiB + `JSON_LEXER_MAX_VALUE_BYTES` rationale — an id token with many group claims is several + KB). Parse failure, size overrun, `v` mismatch, or a **fingerprint that does not match + the live config** => return `null` (treat as "no cache"), never throw into the sign-in + path. The fingerprint re-check is defence in depth against a copied/renamed/hostile file + whose name happens to collide. +- **clear():** `Files.deleteIfExists(target)`. + +## Integration into `OidcDeviceAuth` + +All four touch points sit under the existing `ReentrantLock`, so persistence I/O is +already serialised with sign-in/refresh/clear and needs no new locking. + +1. **Lazy load**, once, at the top of the locked section in `signIn()` and `getToken()`, + guarded by a `boolean storeLoadAttempted` flag: + ```java + if (tokenStore != null && !storeLoadAttempted) { + storeLoadAttempted = true; // set first: a bad file is not retried every call + PersistedToken t = tokenStore.load(storeKey); + if (t != null) { + // validate the SERVED token kind exactly as a wire token (reuse validateTokenChars); + // ignore the file on any failure rather than throw + accessToken = t.getAccessToken(); + idToken = t.getIdToken(); + refreshToken = t.getRefreshToken(); + expiresAtMillis = t.getExpiresAtMillis(); + tokenTtlMillis = t.getTokenTtlMillis(); + } + } + ``` + Nice side effect: after a restart with a persisted refresh token, `getToken()` works as + the *first* call (no explicit `signIn()` needed) — a clean fit for the + `Sender...httpTokenProvider(auth::getToken)` pattern. It may cost one silent refresh + round-trip, which is already inside `getToken()`'s documented contract. + +2. **Save** at the end of `storeTokens(...)` (`OidcDeviceAuth.java:1261`), after the + in-memory fields are set: + ```java + persistIfConfigured(); // builds a PersistedToken from the current fields, calls tokenStore.save + ``` + - On the interactive sign-in: always write (the refresh token is new). + - On a refresh: **write only when the refresh token changed** (rotation). A + non-rotating IdP returns no new refresh token (`storeTokens` keeps the old one), so + the on-disk refresh token is still valid and we skip the write — keeping `getToken()` + cheap on the hot path. A rotating IdP issues a new refresh token, which we *must* + persist or a later restart would replay a revoked one; that write is unavoidable. + - **Best-effort:** wrap `save` so an I/O failure logs one warning and is swallowed — + a disk problem must never fail an otherwise-valid sign-in. The token is good in + memory regardless. + +3. **clear()** in `clearCache()` (`OidcDeviceAuth.java:361`): after nulling the + in-memory fields, call `tokenStore.clear(storeKey)` so the next `signIn()` genuinely + re-prompts. Leave `storeLoadAttempted = true` so we do not immediately reload the file + we just deleted. + +4. **close()** (`OidcDeviceAuth.java:390`): no change. `FileTokenStore` holds no native + resources; `TokenStore` is deliberately **not** `Closeable`. + +The `storeKey` is built once in the constructor from the already-parsed config +(`clientIdEncoded` decodes back, or capture the raw values before encoding; +`tokenEndpoint`/`deviceAuthorizationEndpoint` `Endpoint` -> canonical origin+path). + +## Threat model / security + +Persisting a refresh token widens the attack surface versus memory-only; this is the +whole reason persistence is **opt-in**. Mitigations, mapped to PR #52's existing posture: + +- **At-rest exposure.** Anyone who can read the file (the user, root, a backup) gets a + credential valid until the IdP expires/revokes it. Mitigation: 0600 file in a 0700 dir, + created with those perms (no open window). This matches what `gcloud`, `aws`, and `gh` + do. Residual risk is explicit in the README ("enabling persistence stores a long-lived + credential on disk; use a `TokenStore` backed by your OS keychain to avoid that"). +- **Tampered/forged file = untrusted input.** A file is attacker-writable, so on load we + (a) bound its size, (b) parse defensively and ignore garbage, (c) re-check the + in-file fingerprint against the live config, and (d) run `validateTokenChars` on the + served token before it can become an `Authorization: Bearer` value or a `_sso` password + — exactly the CR/LF / non-ASCII rejection PR #52 applies to IdP responses + (`OidcDeviceAuth.java:935-951`). A bad file degrades to an interactive sign-in; it never + injects into a request or throws token bytes into a message. +- **Never log/echo secrets.** The store never logs token contents and never embeds file + contents in an exception, upholding PR #52's "tokens never leak into logs or exceptions" + rule. Only paths and `IOException` kinds appear in the one best-effort warning. +- **Wrong-identity serving.** Prevented by the `TokenStoreKey` (filename hash) plus the + in-file fingerprint re-check; a token minted for server/scope/audience A is never served + to a process configured for B. +- **Plaintext-transport interaction.** Unchanged — the IdP endpoints still require + `https` (loopback excepted), so the refresh token only ever crossed the wire encrypted; + persistence does not introduce a new cleartext path. + +## File format and confidentiality (Q1: plaintext vs encoded) + +**The file is plaintext JSON. Confidentiality at rest comes from filesystem +permissions (0600/0700), not from encoding or encryption.** Rationale: + +- **Encoding (base64 / obfuscation) is not security and would not be added as if it + were.** Anyone who can read the file can reverse base64 in one step; it protects + nothing while implying protection — the opposite of PR #52's habit of being explicit + about its trust boundaries. It would also hurt the two things plaintext buys us: + cross-language interop and debuggability. +- **Built-in encryption is a non-goal because of key management** (see Non-goals): a key + beside the ciphertext is theatre, and a key from an OS secret store needs native code + we cannot depend on. Worse for this project specifically — a shared *encrypted* format + would force the Java and Python clients to agree on a cipher *and* a key-derivation + scheme to interoperate on one file. Plaintext JSON is the only format every language + reads and writes with zero dependencies, which is exactly what "Java is the reference + for Python" needs. +- **Real at-rest encryption is delivered through the `TokenStore` SPI** — a caller who + needs it plugs in a keychain/KMS-backed store (macOS Keychain, Windows DPAPI, Linux + Secret Service, Vault). If they also need cross-language sharing, they implement the + same custom store in each client; that is their explicit choice, not our default. +- **This matches the ecosystem.** `gcloud`, `aws`, and `gh` all persist tokens as + plaintext under owner-only permissions. The README will state the residual risk plainly + ("persistence writes a long-lived credential to disk in plaintext, protected by file + permissions; back the store with your OS keychain to avoid that"). + +Writer correctness: a refresh token is an opaque IdP string, so the JSON writer **must** +escape `"`, `\`, and control characters (`< 0x20` as `\uXXXX`); the existing `JsonLexer` +already decodes escapes on read (a PR #52 change). Base64-ing token *values* would dodge +escaping, but proper escaping is trivial and keeps the file readable — not worth it. + +## On-disk interop contract (frozen cross-language spec) + +Both clients MUST agree on these to the byte, or they will not share a file (a mismatch +is benign for *correctness* — the fingerprint re-check below still prevents wrong-identity +serving — but it defeats *sharing*, leaving each client to re-prompt). + +- **Directory:** `${questdb.client.oidc.token.store.dir}` if set, else + `${user.home}/.questdb/oidc-tokens/`. Created `rwx------` (0700). +- **File name:** `.json`, where `` is the lowercase hex SHA-256 of the + UTF-8 **canonical identity string**, NUL-separated so no field can be confused with a + separator: + ``` + "questdb-oidc-token-v1" \0 clientId \0 canon(tokenEndpoint) \0 + canon(deviceAuthorizationEndpoint) \0 scope \0 (audience ?? "") \0 (groupsInToken?"1":"0") + ``` + `canon(endpoint)` = `lower(scheme) "://" lower(host) ":" port path`, with the port + always explicit (the device-flow default 443/80 when absent) and `path` the parsed + path (no fragment). The hash is a *bucketing* key only; correctness rests on the + in-file fingerprint, so slight normalization drift across languages costs at most a + missed share, never a wrong token. +- **Schema** (file perms `rw-------`, 0600): + ```json + { + "v": 1, + "client_id": "questdb", + "token_endpoint": "https://idp.example.com/as/token.oauth2", + "device_authorization_endpoint": "https://idp.example.com/as/device_authz.oauth2", + "scope": "openid", + "audience": "api://billing", + "groups_in_token": false, + "access_token": "...", + "id_token": "...", + "refresh_token": "...", + "expires_at_millis": 1730000000000, + "token_ttl_millis": 300000 + } + ``` + The first seven fields are the **non-secret fingerprint**; on load both clients re-check + them against the live config and ignore the file on mismatch (defence in depth against a + hash collision or a copied/renamed file). `expires_at_millis` is absolute wall-clock, so + it is portable across a restart and across machines that share a clock. + + A field whose value is null - an absent `audience`, or a token kind the grant did not + return (e.g. no `id_token`) - is **omitted entirely**, not written as JSON `null`. QuestDB's + `JsonLexer` reports a bare `null` and a quoted `"null"` identically, so omission is the only + encoding under which every present value round-trips verbatim (a token equal to the string + `"null"` included); a reader treats an absent field as null. The Python client MUST do the + same: omit null fields on write, and treat an absent field as null on read. +- **Write protocol (atomicity):** write a sibling temp file created with 0600, flush, then + **atomically rename** over the target — Java `Files.move(tmp, target, ATOMIC_MOVE, + REPLACE_EXISTING)`, Python `os.replace(tmp, target)`. Both are `rename(2)` on POSIX + (atomic) and atomic on Windows. A crash or an overlapping reader sees the whole old or + whole new file, never a torn credential. This is the one *mandatory* multi-writer + guarantee and it interoperates trivially. + +## Cross-process and cross-language coordination (Q2) + +Two layers; the first is mandatory, the second handles the one case the first cannot. + +**Layer 1 — atomic replacement (always; cross-language-safe).** The write protocol above +makes every update all-or-nothing, so any mix of processes and languages sharing one file +(two notebook kernels, a restart overlapping the old process, a Java writer and a Python +reader) is *integrity-safe*: no torn reads, no partial credential. For the common case — +an IdP that does **not** rotate refresh tokens — this is fully sufficient: every process +holds the same stable refresh token, each independently refreshes to mint its own access +token, and last-writer-wins on the file is harmless because access tokens are +interchangeable and the fingerprint fields are identical for one identity. + +**Layer 2 — a lock-file critical section (for rotating refresh tokens).** When the IdP +*rotates* the refresh token on every refresh (Auth0 public clients, OAuth 2.1 BCP +guidance), bare last-writer-wins races: two processes load RT1, both refresh, the IdP +invalidates RT1, one wins and the loser's RT1 is now revoked → an unnecessary interactive +re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per identity: + +- **Use a lock *file*, not an OS advisory lock.** Java `FileLock` maps to `fcntl` POSIX + record locks on Unix while Python's `fcntl.flock` is BSD `flock`; the two **do not + interoperate on Linux**. A lock file acquired with `O_CREAT|O_EXCL` (Java + `Files.createFile`, Python `os.open(..., O_CREAT|O_EXCL)` / `open(p,"x")`) is a plain + filesystem primitive that interoperates trivially. The contract mandates the lock-file + scheme; OS advisory locks are out. +- **Lock file:** `.lock` beside the token file, containing the holder's + `pid@host` and a creation timestamp for debugging. Acquire by exclusive-create; on + contention, spin with short backoff up to a small acquire budget (~3s); if it still + cannot be acquired, **proceed without it** (degrade to Layer 1) rather than fail a + sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned + and stolen, so a crashed holder cannot wedge others. The window must dominate the + worst-case time a live holder can hold the lock: the refresh under the lock runs + send + await + parse, plus a body drain on a parse failure, each separately bounded by + the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s — never the interactive + wait, which is not held under the lock. 10 minutes stays safely above that ~480s. +- **Protocol (under the existing in-process `ReentrantLock`, only when a refresh is + needed):** + 1. acquire `.lock`; + 2. **re-read the token file** — another process may have just refreshed; + 3. if the freshly read served token is now valid, adopt it (re-running + `validateTokenChars`) and **skip the network**; + 4. else POST the refresh with the current refresh token; `storeTokens()` writes the + new token atomically *inside* the lock; + 5. release (delete `.lock`). + + The interactive device flow does **not** hold the lock file (coordinating human prompts + across processes is overkill and would hold a cross-process lock for up to 30 min); two + cold processes may each prompt once, after which later processes read the persisted + refresh token. Lock ordering is always in-process lock then lock file (leaf), so no + deadlock. + +- **SPI shape:** keep `TokenStore` simple for the no-coordination case and add one + optional hook, e.g. `default T inLock(TokenStoreKey, Supplier action)` that just + runs `action` (no lock). `FileTokenStore` overrides it with the lock-file protocol; + `OidcDeviceAuth` wraps its refresh step in `inLock` and does the re-read-then-decide + (steps 2–4) as the action body. A store with no cross-process concern stays a plain + load/save/clear. + +**Staging.** Layer 1 is required and small; Layer 2 is only needed for rotating IdPs. +Both can ship together, or Layer 1 first with Layer 2 as a fast-follow — but the lock-file +protocol above should be frozen into the spec now so the Python client implements the +same one. (See Decisions.) + +## Decisions + +Resolved: +- **Full token blob (option A)** — persist access + id + refresh + expiry; no change to + the audited `signIn`/`getToken`/`tryRefresh` gate. +- **Plaintext JSON**, confidentiality via file permissions; encryption only via the SPI + (Q1). +- **`System.err`** for the one best-effort persistence-failure warning. +- **Opt-in** (no store unless the caller sets one). +- **Ship `FileTokenStore`** as the default; keychain/KMS via the SPI. +- **Frozen on-disk contract** (path, hash, schema, atomic write, lock-file protocol), + because the Python client will mirror it. + +Still to confirm: +1. **Layer 2 (lock file) now or fast-follow?** Layer 1 (atomic replace) is mandatory and + small; Layer 2 only matters for rotating-refresh-token IdPs. Either way the protocol is + frozen in the spec above. Recommendation: ship both together — the rotating case is + realistic and the lock-file code is modest. + +## Testing strategy + +- `FileTokenStore`: round-trip save/load; perms are 0600/0700 (skip on non-POSIX); + ATOMIC_MOVE leaves no `.tmp`; corrupt/oversized/garbage file -> `load` returns null, no + throw; fingerprint mismatch -> null; a token with CR/LF/non-ASCII -> rejected on load. +- `OidcDeviceAuth` against a fake `TokenStore` + the existing `MockOidcServer`: + - sign in -> a second *new* instance with the same store skips the device flow and only + hits the token endpoint (silent refresh) — assert the device-auth endpoint is never + called. + - quick restart with an unexpired persisted access token -> zero network. + - rotating refresh token -> file rewritten each refresh; non-rotating -> written once. + - `clearCache()` deletes the file -> next `signIn()` re-runs the device flow. + - `save` throwing -> sign-in still returns a valid token (best-effort), warning emitted. + - `getToken()` as the first call after a restore (no `signIn()`), refresh path only. +- `assertMemoryLeak` around tests that build a real `OidcDeviceAuth` (native lexer). + +## Open questions + +- **Default location on Windows** — `${user.home}/.questdb` is fine functionally, but the + ACL hardening story there is unfinished: POSIX perms do not apply, so the file relies on + the user-profile directory's default ACL. Tightening via `AclFileAttributeView` + (owner-only) is a possible follow-up; the Python client will face the same gap. +- **Windows lock-file interop** — the `O_EXCL` lock-file scheme works on Windows + (`CREATE_NEW`), but the staleness/steal heuristic must tolerate Windows' stricter + delete-while-open semantics; verify before relying on Layer 2 cross-platform. + +Notes carried from the discussion (not open): +- Python persistence does not exist yet and will be built **after** the Java client, using + this as the base — hence the frozen contract. The single most important thing Python + must copy verbatim is the **lock-file** coordination (not an OS advisory lock), since + Java `FileLock` (`fcntl`) and Python `flock` do not interoperate. diff --git a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java index a1cfefb41..3e691abea 100644 --- a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -21,6 +21,10 @@ public static void main(String[] args) { // available (best-effort; skipped on a headless host). To print only, pass options: // import io.questdb.client.cutlass.auth.DeviceCodePrompt; // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT)) + // To survive a restart without prompting again, persist the token with a TokenStore - the restarted + // process resumes from the saved refresh token instead of re-running the device flow: + // import io.questdb.client.cutlass.auth.FileTokenStore; + // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().tokenStore(FileTokenStore.atDefaultLocation())) try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { auth.signIn(); // sign in once (prompts on first use, then caches and refreshes silently) From e0010d6919412cd16578c3eb04098ab4a8309f93 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 29 Jun 2026 19:25:29 +0100 Subject: [PATCH 059/192] Cap token-store lock budget; add persistence tests Address the moderate findings from the OIDC token-persistence review. getToken() runs on the latency-sensitive flush and reconnect path, but persistence routed it through FileTokenStore's cross-process lock, which could spin up to the acquire budget before a silent refresh. The advanced FileTokenStore constructor accepted an unbounded budget, so a misconfiguration could stall a flush. Cap the acquire budget at 30s, and reconcile the HttpTokenProvider SPI and the OidcDeviceAuth class docs, which still claimed getToken() never blocks: it never waits behind an interactive sign-in, but a coordinated silent refresh may briefly wait for the store lock. Add the missing tests on the security-critical persistence branches (CI had not covered these files): schema-version mismatch, per-field fingerprint mismatch (including groups_in_token and the audience nullableEquals path), the httpTimeoutMillis cap, a control-char JSON round-trip, the 0600 lock-file permissions, getToken() degrading to a lock-free refresh while a peer holds the lock, and a golden-hash anchor pinning the cross-language on-disk file-name contract. Also assert the previously-unchecked load/clear/lock store counters, the load-once guarantee, and clearCache() not reloading a just-cleared entry. FileTokenStoreTest 19 -> 26, OidcDeviceAuthPersistenceTest 14 -> 18; all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 6 +- .../client/cutlass/auth/FileTokenStore.java | 16 +- .../client/cutlass/auth/OidcDeviceAuth.java | 6 +- .../test/cutlass/auth/FileTokenStoreTest.java | 176 ++++++++++++++++++ .../auth/OidcDeviceAuthPersistenceTest.java | 113 +++++++++++ 5 files changed, 312 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index 6ac1bd096..e8e306577 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -36,8 +36,10 @@ *

    * {@link #getToken()} runs on the sender's flush and reconnect paths: it must return promptly and must * not block on interactive input. A quick silent token refresh is fine, but it must not start an - * interactive sign-in. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the - * connection attempt (WebSocket). + * 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. An + * exception from {@link #getToken()} fails the in-flight flush (HTTP) or the connection attempt (WebSocket). * * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider) */ 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 index 28b91efc3..58dfae3fb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -105,6 +105,12 @@ public final class FileTokenStore implements TokenStore { // 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; 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 once @@ -124,7 +130,9 @@ public FileTokenStore(Path directory) { * * @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 + * 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: one * refresh under the lock runs send + await + parse plus a body drain, each @@ -142,6 +150,12 @@ public FileTokenStore(Path directory, long lockAcquireBudgetMillis, long lockSta 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) { 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 index df27a54b3..037074e1c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -85,8 +85,10 @@ * 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: it fails fast with an - * {@link OidcAuthException} so a request/flush path never stalls. To abort a waiting sign-in, call + * 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, + * bounded by {@link Builder#httpTimeoutMillis(int)} 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 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 index 1f2511ad0..1ec96b9ab 100644 --- 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 @@ -85,6 +85,53 @@ public void testAdvancedConstructorRejectsNonPositiveTimings() throws Exception }); } + @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 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(() -> { @@ -102,6 +149,26 @@ public void testClearDeletesFile() throws Exception { }); } + @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 testCorruptFileReturnsNull() throws Exception { assertMemoryLeak(() -> { @@ -163,6 +230,34 @@ public void testFingerprintMismatchReturnsNull() throws Exception { }); } + @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 testInLockDegradesWhenDirectoryUnusable() throws Exception { assertMemoryLeak(() -> { @@ -340,6 +435,29 @@ public void testLoadMissingReturnsNull() throws Exception { }); } + @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 pid@host 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 testNoLeftoverTempFileAfterSave() throws Exception { assertMemoryLeak(() -> { @@ -376,6 +494,39 @@ public void testOversizedFileReturnsNull() throws Exception { }); } + @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")); @@ -409,6 +560,31 @@ public void testSaveThenLoadRoundTrip() throws Exception { }); } + @Test + public void testSchemaVersionMismatchReturnsNull() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + FileTokenStore store = new FileTokenStore(dir); + TokenStoreKey key = sampleKey(); + Files.createDirectories(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(() -> { 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 index 892282cd6..268d6a56e 100644 --- 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 @@ -25,6 +25,7 @@ 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; @@ -55,6 +56,30 @@ public class OidcDeviceAuthPersistenceTest { @Rule public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build(); + @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(() -> { @@ -84,6 +109,41 @@ public void testClearCacheDeletesPersistedEntry() throws Exception { }); } + @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 testGetTokenAsFirstCallAfterRestore() throws Exception { assertMemoryLeak(() -> { @@ -104,6 +164,33 @@ public void testGetTokenAsFirstCallAfterRestore() throws Exception { }); } + @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 + Files.createFile(dir.resolve(keyFor(server).hash() + ".lock")); + try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir, 200, 600_000)).build()) { + long start = System.currentTimeMillis(); + Assert.assertEquals("ACCESS-2", auth.getToken()); + long elapsed = System.currentTimeMillis() - start; + Assert.assertTrue("getToken must degrade promptly, not stall, was " + elapsed, elapsed < 10_000); + } + 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(() -> { @@ -261,6 +348,7 @@ public void testRotatingRefreshRewritesStore() throws Exception { 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()); } @@ -336,6 +424,31 @@ public void testSaveFailureThenRefreshDoesNotReplayRevokedToken() throws Excepti }); } + @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 testTamperedFarFutureExpiryIsBoundedNotTrustedForever() throws Exception { assertMemoryLeak(() -> { From 71e01f26dd80c94ae2bbbe327e9197446fc043b9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 29 Jun 2026 23:08:53 +0100 Subject: [PATCH 060/192] Harden OIDC token store lock and expiry clamp FileTokenStore released its cross-process lock by bare path, so a hold that outran lockStaleMillis (and was therefore stolen and recreated by a peer) deleted the peer's live lock on release, admitting a third acquirer alongside it and breaking the mutual exclusion the lock guards. The release now verifies ownership: acquireLock stamps a unique nonce (pid@host + timestamp + UUID) into the lock and returns it, and releaseLock deletes the lock only when it still carries that nonce. A stamp that cannot be written degrades to a lock-free refresh rather than holding an unverifiable lock. The frozen cross-language contract in design/oidc-token-persistence.md now documents the owner stamp and the ownership-verified release so other clients mirror it. OidcDeviceAuth.adopt() capped a loaded expiry but never floored it, so a tampered expires_at_millis near Long.MIN_VALUE made the validity check (now < expiresAtMillis - skew) underflow to a large positive and serve a garbage-expiry token as valid forever. adopt() now clamps the expiry to [0, now + maxLife], keeping the check underflow-safe while an already-expired entry still falls through to a refresh. Add tests: a peer-stolen lock survives our release; a tampered far-past expiry is refreshed rather than served; the parseLast() truncated- document reject; and a real FileTokenStore.save rename failure throws OidcAuthException and leaves no .tmp file. Both regression guards were proven to fail with their fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 89 ++++++++++++++----- .../client/cutlass/auth/OidcDeviceAuth.java | 10 ++- .../test/cutlass/auth/FileTokenStoreTest.java | 78 ++++++++++++++++ .../auth/OidcDeviceAuthPersistenceTest.java | 25 ++++++ design/oidc-token-persistence.md | 30 ++++--- 5 files changed, 196 insertions(+), 36 deletions(-) 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 index 58dfae3fb..628a245b2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -52,6 +52,7 @@ import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.util.Set; +import java.util.UUID; /** * The default {@link TokenStore}: one plaintext JSON file per identity under a directory, with the @@ -199,26 +200,25 @@ public void clear(TokenStoreKey key) { @Override public boolean inLock(TokenStoreKey key, CriticalSection action) { Path lock = null; - boolean held; + // 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; try { ensureDirectory(); lock = lockFile(key); - held = acquireLock(lock); + nonce = acquireLock(lock); } catch (IOException e) { // could not prepare the lock directory or file; run without the 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. - held = false; + nonce = null; } try { return action.run(); } finally { - if (held) { - try { - Files.deleteIfExists(lock); - } catch (IOException ignore) { - // best-effort release; a leftover lock goes stale and the next acquirer steals it - } + if (nonce != null) { + releaseLock(lock, nonce); } } } @@ -281,6 +281,15 @@ private static void createLockFile(Path lock) throws IOException { } } + private static String newLockNonce() { + // a per-acquisition owner stamp: the pid@host and the acquire time are human-readable debugging aids, + // 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 + return ManagementFactory.getRuntimeMXBean().getName() // typically pid@host + + ' ' + System.currentTimeMillis() + + ' ' + UUID.randomUUID(); + } + private static boolean nullableEquals(String keyValue, StringSink fileValue) { boolean fileHasValue = fileValue.length() > 0; if (keyValue == null) { @@ -402,6 +411,27 @@ private static void putStringMember(StringSink sink, String name, CharSequence v putString(sink, value); } + private static void releaseLock(Path lock, String nonce) { + // release our own lock only: re-read it and delete it solely when it still carries our nonce. A hold + // that outran lockStaleMillis may have been judged stale and stolen (deleted 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 = Files.readAllBytes(lock); + if (nonce.equals(new String(content, StandardCharsets.UTF_8))) { + Files.deleteIfExists(lock); + } + // otherwise a peer now owns this lock file; leave it for that owner (or staleness) to reclaim + } catch (NoSuchFileException e) { + // already gone (stolen and not yet recreated, or removed elsewhere); nothing to release + } catch (IOException ignore) { + // best-effort release; a leftover lock goes stale and the next acquirer steals it + } + } + private static void restrictToOwner(Path directory) { // best-effort: the at-rest protection of the plaintext token files is exactly these owner-only // directory permissions, so tighten a pre-existing directory rather than trust whatever it had. On a @@ -467,25 +497,40 @@ private static void writeAndFlush(Path file, byte[] content) throws IOException } } - private static void writeLockHolder(Path lock) { - // record the holder (pid@host) and a creation timestamp for debugging only; never fail acquisition - // over it. Staleness is judged by the file's mtime, not by parsing this content + private static boolean writeLockHolder(Path lock, String nonce) { + // stamp the lock with the owner nonce. Unlike the staleness mtime (which only needs to be recent), + // this content is what releaseLock checks before deleting, so it must be written reliably; report a + // failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release. + // Writing also refreshes the mtime, which is what isStale reads try { - String holder = ManagementFactory.getRuntimeMXBean().getName() // typically pid@host - + ' ' + System.currentTimeMillis(); - Files.write(lock, holder.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); - } catch (Exception ignore) { - // best-effort metadata only + Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + return true; + } catch (Exception e) { + return false; } } - private boolean acquireLock(Path lock) { + private String acquireLock(Path lock) { + // 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(); final long deadline = System.currentTimeMillis() + lockAcquireBudgetMillis; while (true) { try { createLockFile(lock); - writeLockHolder(lock); - return true; + if (writeLockHolder(lock, nonce)) { + return nonce; + } + // the exclusive create won the lock but the owner nonce could not be stamped, so releaseLock + // could not later prove ownership and would risk deleting a peer's lock; drop the file we just + // created and degrade to a lock-free refresh rather than hold an unverifiable lock + try { + Files.deleteIfExists(lock); + } catch (IOException ignore) { + // another acquirer may have removed it; the next createLockFile settles the race + } + return null; } catch (FileAlreadyExistsException e) { if (isStale(lock)) { // a crashed holder left the lock behind; steal it @@ -498,11 +543,11 @@ private boolean acquireLock(Path lock) { // between several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin } if (System.currentTimeMillis() >= deadline) { - return false; // give up and run without the lock rather than stall a sign-in + return null; // give up and run without the lock rather than stall a sign-in } Os.sleep(LOCK_POLL_SLICE_MILLIS); } catch (IOException e) { - return false; // unexpected IO; degrade to no lock + return null; // unexpected IO; degrade to no lock } } } 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 index 037074e1c..1e51f4908 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1031,11 +1031,15 @@ private boolean adopt(PersistedToken token) { refreshToken = token.getRefreshToken(); // 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. Capping (not flooring) the expiry preserves an already-expired - // entry, so a stale access token still falls through to a refresh rather than being served forever. + // 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; tokenTtlMillis = Math.max(0L, Math.min(token.getTokenTtlMillis(), maxTokenLifeMillis)); - expiresAtMillis = Math.min(token.getExpiresAtMillis(), System.currentTimeMillis() + maxTokenLifeMillis); + expiresAtMillis = Math.max(0L, Math.min(token.getExpiresAtMillis(), System.currentTimeMillis() + maxTokenLifeMillis)); // it is already on disk, so a later non-rotating refresh must not rewrite the file lastPersistedRefreshToken = refreshToken; return true; 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 index 1ec96b9ab..7c5d6c9d5 100644 --- 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 @@ -342,6 +342,37 @@ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception { }); } + @Test + public void testInLockReleaseDoesNotDeleteAStolenLock() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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(() -> { @@ -542,6 +573,37 @@ public void testPermissionsOwnerOnly() throws Exception { }); } + @Test + public void testSaveFailureLeavesNoTempFileAndThrows() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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(() -> { @@ -604,6 +666,22 @@ public void testSpecialCharactersAndNullsRoundTrip() throws Exception { }); } + @Test + public void testTruncatedJsonReturnsNull() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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)); + }); + } + private static TokenStoreKey sampleKey() { return new TokenStoreKey("questdb", "https://idp.example.com:443/token", "https://idp.example.com:443/device", "openid", null, false); 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 index 268d6a56e..a7c72ab2c 100644 --- 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 @@ -480,6 +480,31 @@ public void testTamperedFarFutureExpiryIsBoundedNotTrustedForever() throws Excep }); } + @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(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 0e46de02c..de7d7f13a 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -369,16 +369,24 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i `Files.createFile`, Python `os.open(..., O_CREAT|O_EXCL)` / `open(p,"x")`) is a plain filesystem primitive that interoperates trivially. The contract mandates the lock-file scheme; OS advisory locks are out. -- **Lock file:** `.lock` beside the token file, containing the holder's - `pid@host` and a creation timestamp for debugging. Acquire by exclusive-create; on - contention, spin with short backoff up to a small acquire budget (~3s); if it still - cannot be acquired, **proceed without it** (degrade to Layer 1) rather than fail a - sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned - and stolen, so a crashed holder cannot wedge others. The window must dominate the - worst-case time a live holder can hold the lock: the refresh under the lock runs - send + await + parse, plus a body drain on a parse failure, each separately bounded by - the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s — never the interactive - wait, which is not held under the lock. 10 minutes stays safely above that ~480s. +- **Lock file:** `.lock` beside the token file, containing a unique per-acquisition + owner stamp — the holder's `pid@host`, a creation timestamp, and a random nonce. + Acquire by exclusive-create; on contention, spin with short backoff up to a small + acquire budget (~3s); if it still cannot be acquired, **proceed without it** (degrade to + Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes) + is treated as abandoned and stolen, so a crashed holder cannot wedge others. The window + must dominate the worst-case time a live holder can hold the lock: the refresh under the + lock runs send + await + parse, plus a body drain on a parse failure, each separately + bounded by the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s — never the + interactive wait, which is not held under the lock. 10 minutes stays safely above that + ~480s. +- **Release verifies ownership.** A holder releases by re-reading the lock and deleting it + **only when it still carries that holder's own owner stamp**, never by bare path. Should + a hold ever outrun the staleness window and be stolen and recreated by a peer, the + original holder must not delete the peer's live lock on release (which would admit a + third acquirer alongside the peer and break mutual exclusion). Each implementation + checks only its own stamp; it never has to parse another implementation's stamp, so the + random nonce keeps the check exact without coupling the language clients. - **Protocol (under the existing in-process `ReentrantLock`, only when a refresh is needed):** 1. acquire `.lock`; @@ -387,7 +395,7 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i `validateTokenChars`) and **skip the network**; 4. else POST the refresh with the current refresh token; `storeTokens()` writes the new token atomically *inside* the lock; - 5. release (delete `.lock`). + 5. release (delete `.lock` only if it still carries our own owner stamp). The interactive device flow does **not** hold the lock file (coordinating human prompts across processes is overkill and would hold a cross-process lock for up to 30 min); two From 9be7c282a28224f6cab0834963000d489ee88157 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 29 Jun 2026 23:23:52 +0100 Subject: [PATCH 061/192] Harden token-store load and key validation FileTokenStore.load read the file with Files.readAllBytes after a separate Files.size check. Because the file is attacker-writable, a concurrent grow between the two would make readAllBytes allocate gigabytes and throw OutOfMemoryError - an Error that the best-effort RuntimeException guard in OidcDeviceAuth.maybeLoadFromStore does not catch, so a bad file aborted sign-in instead of degrading. A new readBounded reads through a FileChannel into a buffer capped at the reported size (already bounded by MAX_FILE_BYTES) plus one byte, so a file that grew past its reported size is rejected, never allocated. TokenFileParser tracked only object depth, so an array-wrapped object ([ {..} ]) extracted its fields through the depth gate. The parser now marks any array as malformed and parseAndVerify rejects a shape that is not a single flat JSON object, keeping the on-disk format strict. TokenStoreKey now rejects a null clientId, tokenEndpoint, deviceAuthorizationEndpoint or scope with a clear OidcAuthException instead of surfacing a raw NullPointerException deep in a store's serialize/fingerprint path, and normalises an empty audience to null so getAudience(), hash() and the save/load round-trip agree that an absent audience is null (an empty audience previously broke its own round-trip). parseAndVerify now bulk-copies the file bytes into native memory via a new DirectUtf8Sink.put(byte[], int, int) instead of a byte-by-byte loop. Add tests for the array-wrapped reject, the empty-audience normalisation, and the null-required-field rejection; each was proven to fail with its fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 60 +++++++++++---- .../client/cutlass/auth/TokenStoreKey.java | 15 +++- .../client/std/str/DirectUtf8Sink.java | 17 +++++ .../test/cutlass/auth/FileTokenStoreTest.java | 75 +++++++++++++++++++ 4 files changed, 150 insertions(+), 17 deletions(-) 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 index 628a245b2..ba12a4a2f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -228,20 +228,15 @@ public PersistedToken load(TokenStoreKey key) { Path file = tokenFile(key); byte[] bytes; try { - if (!Files.exists(file)) { - return null; - } - long size = Files.size(file); - if (size <= 0 || size > MAX_FILE_BYTES) { - // an empty or implausibly large file is not a usable entry; ignore it rather than read it - return null; - } - bytes = Files.readAllBytes(file); + 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); } @@ -313,10 +308,8 @@ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) { TokenFileParser parser = new TokenFileParser(); try (DirectUtf8Sink mem = new DirectUtf8Sink(bytes.length); JsonLexer lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES)) { - for (int i = 0; i < bytes.length; i++) { - // putAny accepts any byte; put(byte) is asserted for non-ASCII bytes only - mem.putAny(bytes[i]); - } + // 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 @@ -325,8 +318,9 @@ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) { 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 - if (parser.version != SCHEMA_VERSION) { + // 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) @@ -411,6 +405,35 @@ private static void putStringMember(StringSink sink, String name, CharSequence v putString(sink, value); } + 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 void releaseLock(Path lock, String nonce) { // release our own lock only: re-read it and delete it solely when it still carries our nonce. A hold // that outran lockStaleMillis may have been judged stale and stolen (deleted and recreated) by a peer; @@ -623,10 +646,17 @@ private static final class TokenFileParser implements JsonParser { int 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; 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 index 82da526f8..f93bb69ae 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java @@ -70,13 +70,24 @@ public TokenStoreKey( 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 + // 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; - this.audience = audience; + // 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, audience, groupsInToken); + this.hash = computeHash(clientId, tokenEndpoint, deviceAuthorizationEndpoint, scope, this.audience, groupsInToken); } public String getAudience() { 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..1095b03d2 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,22 @@ 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) { + 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/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java index 7c5d6c9d5..e3e8ff1d6 100644 --- 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 @@ -103,6 +103,27 @@ public void testAdvancedConstructorRejectsOverCapAcquireBudget() throws Exceptio }); } + @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(() -> { @@ -181,6 +202,28 @@ public void testCorruptFileReturnsNull() throws Exception { }); } + @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(() -> { @@ -666,6 +709,38 @@ public void testSpecialCharactersAndNullsRoundTrip() throws Exception { }); } + @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(() -> { From 2128e54caa3eeec51868baa24544b6256b29b9c6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 29 Jun 2026 23:35:28 +0100 Subject: [PATCH 062/192] Tighten token-store hardening and hygiene Sanitize the warnPersistence detail. An IO error from the store can carry the operator-supplied store path (questdb.client.oidc.token.store.dir or user.home), which could itself hold terminal-spoofing characters, so route cause.getMessage() through the null-safe sanitizeForDisplay before printing to System.err - matching how every other untrusted display string is sanitized. Reap orphan temp files. A crash between createTempFile and the atomic rename leaves a *.tmp holding a valid-at-the-time refresh token; nothing ever steals it, so they would accumulate across crashes. save() now best-effort sweeps *.tmp older than lockStaleMillis, leaving a concurrent writer's fresh temp (recent mtime) untouched - so the random per-writer suffix that keeps concurrent saves from colliding stays. Chmod the store directory only on drift. ensureDirectory re-tightens a pre-existing directory on every save and every inLock; read the permissions first and only setPosixFilePermissions when they actually differ, dropping a redundant write syscall in the common case while keeping the re-tighten defense and the non-POSIX fallback. Compare the schema version as a long. The parser narrowed it to an int, so a tampered "v" of 1 + 2^32 truncated to SCHEMA_VERSION and passed the gate; keep the full long and compare as a long. Add tests for the temp-file sweep and the version-overflow reject; each was proven to fail with its fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 42 ++++++++++++++++--- .../client/cutlass/auth/OidcDeviceAuth.java | 8 ++-- .../test/cutlass/auth/FileTokenStoreTest.java | 41 ++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) 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 index ba12a4a2f..d69de51fb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -40,6 +40,7 @@ import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; 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; @@ -245,6 +246,7 @@ public void save(TokenStoreKey key, PersistedToken token) { byte[] content = serialize(key, token); try { ensureDirectory(); + sweepStaleTempFiles(key.hash()); Path target = tokenFile(key); Path tmp = createTempFile(key.hash()); boolean moved = false; @@ -457,16 +459,20 @@ private static void releaseLock(Path lock, String nonce) { private static void restrictToOwner(Path directory) { // best-effort: the at-rest protection of the plaintext token files is exactly these owner-only - // directory permissions, so tighten a pre-existing directory rather than trust whatever it had. On a - // non-POSIX filesystem (Windows) this is unsupported and falls back to the directory's existing ACL + // directory permissions, so re-tighten a pre-existing directory another tool/umask left loose rather + // than trust whatever it had. ensureDirectory runs this on every save and every inLock, so only chmod + // on detected drift - skip the write syscall in the common case where the permissions already match. On + // a non-POSIX filesystem (Windows) this is unsupported and falls back to the directory's existing ACL // (owner-only hardening there, via AclFileAttributeView, is a separate follow-up) try { - Files.setPosixFilePermissions(directory, DIR_PERMS); + if (!DIR_PERMS.equals(Files.getPosixFilePermissions(directory))) { + Files.setPosixFilePermissions(directory, DIR_PERMS); + } } catch (UnsupportedOperationException e) { // non-POSIX FS (e.g. Windows): cannot enforce owner-only perms; keep the inherited ACL warnNoPosixPermsOnce(); } catch (IOException ignore) { - // the directory is not ours to chmod: keep the existing permissions + // the directory is not ours to inspect/chmod: keep the existing permissions } } @@ -614,6 +620,28 @@ private Path lockFile(TokenStoreKey key) { return directory.resolve(key.hash() + ".lock"); } + 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 + try (DirectoryStream stream = Files.newDirectoryStream(directory, hashPrefix + "*.tmp")) { + final long now = System.currentTimeMillis(); + for (Path tmp : stream) { + try { + if (now - Files.getLastModifiedTime(tmp).toMillis() > lockStaleMillis) { + 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"); } @@ -643,7 +671,7 @@ private static final class TokenFileParser implements JsonParser { long expiresAtMillis; boolean groupsInToken; long tokenTtlMillis; - int version; + long version; private int depth; private int field = FIELD_NONE; private boolean malformed; @@ -698,7 +726,9 @@ public void onEvent(int code, CharSequence tag, int position) { if (depth == 1) { switch (field) { case FIELD_VERSION: - version = (int) parseLongOrZero(tag); + // 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); 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 index 1e51f4908..b8cd12cf1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1499,9 +1499,11 @@ private boolean tryRefreshCoordinated() { } private void warnPersistence(String operation, Throwable cause) { - // best-effort persistence: report to System.err and carry on with the in-memory token. The store - // never puts token bytes in its messages, so this cannot leak the secret. - String detail = cause.getMessage(); + // best-effort persistence: report to System.err 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()); System.err.println("questdb client: OIDC token store " + operation + " failed; continuing without persistence" + (detail != null ? " [" + detail + ']' : "")); } 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 index e3e8ff1d6..eb83a441e 100644 --- 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 @@ -709,6 +709,30 @@ public void testSpecialCharactersAndNullsRoundTrip() throws Exception { }); } + @Test + public void testStaleTempFilesAreSweptOnSave() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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 testTokenStoreKeyRejectsNullRequiredFields() throws Exception { assertMemoryLeak(() -> { @@ -757,6 +781,23 @@ public void testTruncatedJsonReturnsNull() throws Exception { }); } + @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); From aba8399dc0d8d642ee982f9d43593690f14d32af Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 29 Jun 2026 23:49:52 +0100 Subject: [PATCH 063/192] Refine token-store cross-process and load semantics Run clear() under the cross-process lock. A bare deleteIfExists let a peer mid-refresh atomically rename a fresh file in just after the delete, resurrecting the entry; clear() now deletes inside inLock (which cleans up its own lock and degrades to lock-free if it cannot acquire one). A Files.isDirectory guard keeps clear() a true no-op on a never-used store rather than creating the directory just to run the locked delete. Cross- process clear stays best-effort: a peer holding a live in-memory token may legitimately re-persist afterwards. Derive the adopted token lifetime from the absolute expiry. adopt() clamped expires_at_millis and token_ttl_millis from the file independently, so a tampered file could make them disagree and throw off effectiveSkewMillis (which caps the skew at half the lifetime). Derive tokenTtlMillis from the clamped expiry instead, so the skew basis always matches the authoritative remaining lifetime; a legitimate file is unaffected. Document the cross-process contract more precisely. The README now notes that lock-file staleness is judged by modification time, so coordination assumes a shared clock (a single machine or synchronized clocks; NFS clock skew can mis-judge it), and that clearCache() is best-effort across processes. The frozen on-disk contract now states the document must be a single flat JSON object, which the parser already enforces by rejecting an array-wrapped or non-object shape - so the Python client mirrors it. Order parseAndVerify before parseLongOrZero to match the alphabetical member ordering. Add tests for the expiry-derived lifetime and the clear-on-empty-store no-op; both were proven to fail with their fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- .../client/cutlass/auth/FileTokenStore.java | 37 ++++++++++++------- .../client/cutlass/auth/OidcDeviceAuth.java | 9 ++++- .../test/cutlass/auth/FileTokenStoreTest.java | 12 ++++++ .../auth/OidcDeviceAuthPersistenceTest.java | 29 +++++++++++++++ design/oidc-token-persistence.md | 4 ++ 6 files changed, 77 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e06a7f677..e22959bf0 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( 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 prints a one-line warning to `System.err` the first time it cannot enforce them. 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 — a tampered, corrupt, oversized, or identity-mismatched entry is ignored (the client falls back to a refresh or an interactive sign-in), and a token carrying control or non-ASCII characters is never placed on the wire. -`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. +`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 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 index d69de51fb..6cb9f7fbc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -191,11 +191,22 @@ public static FileTokenStore atDefaultLocation() { @Override public void clear(TokenStoreKey key) { - try { - Files.deleteIfExists(tokenFile(key)); - } catch (IOException e) { - throw new OidcAuthException(e).put("could not remove the OIDC token store file"); - } + if (!Files.isDirectory(directory)) { + return; // nothing is persisted yet; do not create the directory just to clear it + } + // 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. + inLock(key, () -> { + try { + Files.deleteIfExists(tokenFile(key)); + } catch (IOException e) { + throw new OidcAuthException(e).put("could not remove the OIDC token store file"); + } + return true; + }); } @Override @@ -295,14 +306,6 @@ private static boolean nullableEquals(String keyValue, StringSink fileValue) { return fileHasValue && Chars.equals(keyValue, fileValue); } - private static long parseLongOrZero(CharSequence value) { - try { - return Numbers.parseLong(value); - } catch (NumericException e) { - return 0; - } - } - private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) { if (bytes.length == 0) { return null; @@ -339,6 +342,14 @@ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) { return new PersistedToken(accessToken, idToken, refreshToken, parser.expiresAtMillis, parser.tokenTtlMillis); } + private static long parseLongOrZero(CharSequence value) { + 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); 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 index b8cd12cf1..3e3e7a1bd 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1038,8 +1038,13 @@ private boolean adopt(PersistedToken token) { // 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; - tokenTtlMillis = Math.max(0L, Math.min(token.getTokenTtlMillis(), maxTokenLifeMillis)); - expiresAtMillis = Math.max(0L, Math.min(token.getExpiresAtMillis(), System.currentTimeMillis() + maxTokenLifeMillis)); + long now = System.currentTimeMillis(); + expiresAtMillis = Math.max(0L, Math.min(token.getExpiresAtMillis(), now + maxTokenLifeMillis)); + // derive the trusted lifetime from the clamped absolute expiry, not the file's separately-stored ttl, so + // a tampered file cannot make the two disagree and throw off effectiveSkewMillis (which caps the skew at + // half the lifetime); for a legitimate file the two already agree, and the expiry clamp bounds this to + // [0, maxLife] + tokenTtlMillis = Math.max(0L, expiresAtMillis - now); // it is already on disk, so a later non-rotating refresh must not rewrite the file lastPersistedRefreshToken = refreshToken; return true; 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 index eb83a441e..6956ac56e 100644 --- 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 @@ -170,6 +170,18 @@ public void testClearDeletesFile() throws Exception { }); } + @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 testControlCharactersRoundTrip() throws Exception { assertMemoryLeak(() -> { 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 index a7c72ab2c..736aab201 100644 --- 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 @@ -56,6 +56,35 @@ public class OidcDeviceAuthPersistenceTest { @Rule public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build(); + @Test(timeout = 30_000) + public void testAdoptDerivesTtlFromExpiry() 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 whose stored ttl (5m) disagrees with its absolute expiry (now + 10m). + // adopt() must derive the trusted lifetime from the authoritative expiry, not the stored ttl, so + // the effectiveSkewMillis basis matches the real remaining lifetime + long now = System.currentTimeMillis(); + fake.loadReturns = new PersistedToken("ACCESS-1", null, "REFRESH-1", now + 600_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"); + long expiry = readPrivateLong(auth, "expiresAtMillis"); + Assert.assertTrue("ttl must be derived from the ~10m expiry, not the stored 5m: " + ttl, + ttl >= 9 * 60_000L); + Assert.assertTrue("ttl must not exceed the clamped 1h lifetime: " + ttl, ttl <= 60 * 60_000L); + Assert.assertTrue("ttl must match expiresAtMillis - now within tolerance", + Math.abs(ttl - (expiry - now)) < 5_000L); + } + 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 testBuilderRejectsHttpTimeoutAboveCap() throws Exception { assertMemoryLeak(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index de7d7f13a..b4d66a1dc 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -337,6 +337,10 @@ serving — but it defeats *sharing*, leaving each client to re-prompt). encoding under which every present value round-trips verbatim (a token equal to the string `"null"` included); a reader treats an absent field as null. The Python client MUST do the same: omit null fields on write, and treat an absent field as null on read. + + The document MUST be a single flat JSON object. A reader rejects any other shape - an array + anywhere (for example a top-level `[ {…} ]` wrapper) or a non-object root - rather than + extract fields from a malformed structure. The Python client MUST do the same. - **Write protocol (atomicity):** write a sibling temp file created with 0600, flush, then **atomically rename** over the target — Java `Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING)`, Python `os.replace(tmp, target)`. Both are `rename(2)` on POSIX From 8469be94343e44d60466b9715d5aa393a27c2c9d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 30 Jun 2026 01:17:49 +0100 Subject: [PATCH 064/192] Enforce OIDC token store lock staleness floor build() now rejects a FileTokenStore whose lockStaleMillis is below 4x httpTimeoutMillis. The cross-process lock presumes a holder has crashed once its lock outlives that staleness window and steals it; if the window is shorter than the worst-case time a live refresh holds the lock (send + await + parse + body drain, each bounded by httpTimeoutMillis), a peer can steal a live holder's lock mid-refresh and reopen the rotating-refresh-token race the lock exists to prevent. The store cannot see the client's timeout, so build() enforces the invariant where both values are known. The default store (600s) and any non-coordinating TokenStore are unaffected. Also add the two load-path security tests this subsystem lacked: - a groups-in-token instance rejects a persisted entry whose served id token carries CR/LF (adopt() must validate the id token, not the access token, in that mode) and falls back to the device flow; - a persisted served token carrying a non-ASCII char (> 0x7e), not just a control char, is rejected on load. Each new test was confirmed to fail without its fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 6 ++ .../client/cutlass/auth/OidcDeviceAuth.java | 22 +++++ .../auth/OidcDeviceAuthPersistenceTest.java | 86 +++++++++++++++++++ 3 files changed, 114 insertions(+) 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 index 6cb9f7fbc..24850d040 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -280,6 +280,12 @@ public void save(TokenStoreKey key, PersistedToken token) { } } + 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) throws IOException { try { Files.createFile(lock, FILE_ATTRS); 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 index 3e3e7a1bd..a1047bf4b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -132,6 +132,11 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 worst case a coordinated refresh can hold a FileTokenStore cross-process lock, as a multiple of + // httpTimeoutMillis: one refresh under the lock runs send + await + parse, plus a body drain on a parse + // failure, each separately bounded by httpTimeoutMillis. build() rejects a FileTokenStore whose + // lock-staleness window does not exceed this, so a peer never judges a live holder's lock stale mid-refresh + private static final int LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE = 4; // 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; @@ -1579,6 +1584,23 @@ public OidcDeviceAuth build() { // 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 (the refresh under the lock is + // bounded by httpTimeoutMillis, up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE times it), or a peer could steal + // a live holder's lock mid-refresh and reopen the rotating-refresh-token race the lock prevents. The + // store cannot see this client's timeout, so enforce the invariant here, where both are known, rather + // than leave the caller to size it by hand. 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); } 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 index 736aab201..21d8d336e 100644 --- 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 @@ -85,6 +85,34 @@ public void testAdoptDerivesTtlFromExpiry() throws Exception { }); } + @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(() -> { @@ -557,6 +585,37 @@ public void testTamperedFileWithCrlfTokenFallsBackToDeviceFlow() throws Exceptio }); } + @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(() -> { @@ -582,6 +641,33 @@ public void testTamperedServedTokenRejectedOnLoad() throws Exception { }); } + @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); + } + }); + } + private static OidcDeviceAuth.Builder baseBuilder(MockOidcServer server) { return OidcDeviceAuth.builder() .clientId("questdb") From b016dd0c1dd2b192124fb2bb5de631b4595a7a52 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 30 Jun 2026 02:41:51 +0100 Subject: [PATCH 065/192] Harden OIDC token-store lock steal and release acquireLock stole an abandoned lock by deleting it by bare path: two acquirers contending over one stale lock could both delete it and a third could delete a peer's freshly created live lock, admitting two holders into the read-refresh-write at once. stealIfStale now captures the lock with an atomic rename to a unique name (so exactly one stealer wins), verifies the captured owner stamp matches the one it judged stale, and on a mismatch restores the peer's lock with a non-clobbering move rather than stealing it. This mirrors releaseLock's own-stamp check and shrinks the race from the whole isStale->delete gap to the gap between two renames. releaseLock read the lock file with an unbounded Files.readAllBytes. The lock file shares the attacker-writable token-store directory, so an inflated lock could throw OutOfMemoryError - an Error the best-effort RuntimeException guards on the getToken()/signIn() path do not catch, aborting the sign-in. readLockHolder now reads it with the same hard cap readBounded applies to the token file and treats an oversized lock as unreadable. Add a concurrent steal-contention test asserting mutual exclusion and no capture-temp leak, and a test that an oversized stale lock is stolen rather than wedging acquisition. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 134 +++++++++++++++--- .../test/cutlass/auth/FileTokenStoreTest.java | 98 +++++++++++++ 2 files changed, 212 insertions(+), 20 deletions(-) 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 index 24850d040..f58d19265 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -52,6 +52,7 @@ 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.Set; import java.util.UUID; @@ -113,6 +114,10 @@ public final class FileTokenStore implements TokenStore { // 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 (pid@host + millis + UUID) is a + // few hundred 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; 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 once @@ -295,6 +300,16 @@ private static void createLockFile(Path lock) throws IOException { } } + 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 + } + } + private static String newLockNonce() { // a per-acquisition owner stamp: the pid@host and the acquire time are human-readable debugging aids, // and the random UUID guarantees two acquisitions never share a stamp even within one pid and one @@ -453,20 +468,49 @@ private static byte[] readBounded(Path file) throws IOException { } } + 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 and delete it solely when it still carries our nonce. A hold - // that outran lockStaleMillis may have been judged stale and stolen (deleted 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. + // 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 = Files.readAllBytes(lock); - if (nonce.equals(new String(content, StandardCharsets.UTF_8))) { + 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; leave it for that owner (or staleness) to reclaim + // 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 ignore) { @@ -578,16 +622,11 @@ private String acquireLock(Path lock) { } return null; } catch (FileAlreadyExistsException e) { - if (isStale(lock)) { - // a crashed holder left the lock behind; steal it - try { - Files.deleteIfExists(lock); - } catch (IOException ignore) { - // another acquirer may have removed it; the next createLockFile settles the race - } - // 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 - } + // 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.currentTimeMillis() >= deadline) { return null; // give up and run without the lock rather than stall a sign-in } @@ -637,6 +676,61 @@ private Path lockFile(TokenStoreKey key) { return directory.resolve(key.hash() + ".lock"); } + 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 isStale->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 staleness: if a peer replaces the stale lock with a fresh one in + // between, isStale reads the fresh mtime and returns false, so we never proceed against a live lock + if (!isStale(lock)) { + 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; + try { + after = readLockHolder(captured); + } 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 unreadable/empty junk), not a live + // lock a peer recreated in the gap + final boolean confirmedStale = 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: put it back rather than steal it. Use a plain + // move (not ATOMIC_MOVE, which maps to rename(2) and would replace the target): if a third party + // claimed the now-free path during our capture window, FileAlreadyExistsException leaves their lock + // intact and we drop our captured copy rather than clobber it; a stray .tmp is reclaimed by + // sweepStaleTempFiles on a later save. + try { + Files.move(captured, lock); + } catch (IOException e) { + 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 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 index 6956ac56e..0b42e4720 100644 --- 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 @@ -38,11 +38,13 @@ import java.io.File; import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; import java.nio.file.attribute.PosixFilePermissions; +import java.util.Arrays; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -182,6 +184,62 @@ public void testClearOnEmptyStoreIsNoOp() throws Exception { }); } + @Test + public void testConcurrentStealContentionPreservesMutualExclusion() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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" race to steal the one abandoned lock. The staleness window (60s) far exceeds + // each ~100ms hold, so a live holder's lock is never judged stale: only the abandoned lock is + // stealable, and the atomic, stamp-verified steal must admit exactly one holder at a time. This is a + // mutual-exclusion invariant guard under steal contention: it exercises the steal path concurrently + // and fails on any gross loss of exclusion. It does not deterministically reproduce the narrow + // isStale->delete race the atomic steal closes - that needs a timing seam this final class does not + // expose - so the fix itself rests on the atomic capture + stamp re-check, not on this test alone. + final int threads = 4; + 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; + }; + + 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(() -> store.inLock(key, section)); + } + for (Thread t : ts) { + t.start(); + } + for (Thread t : ts) { + t.join(); + } + + Assert.assertEquals("every contender must run its critical section", threads, ran.get()); + Assert.assertEquals("the steal must never admit two holders at once", 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(() -> { @@ -580,6 +638,36 @@ public void testOversizedFileReturnsNull() throws Exception { }); } + @Test + public void testOversizedStaleLockIsStolen() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(dir); + FileTokenStore store = new FileTokenStore(dir, 2000, 100); + TokenStoreKey key = sampleKey(); + Path lock = lockFile(dir, key); + // a corrupt/hostile lock far larger than the read cap, backdated past the staleness window. 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(() -> { @@ -819,6 +907,16 @@ 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 Path lockFile(Path dir, TokenStoreKey key) { return dir.resolve(key.hash() + ".lock"); } From ea7c1a82d2be2b97494bb1cd357557fc5cc12ac2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 30 Jun 2026 10:37:43 +0100 Subject: [PATCH 066/192] Harden token-store cross-process lock edge cases The FileTokenStore lock-file protocol had four edge cases where the cross-process mutual exclusion could degrade. Each degrades gracefully (at worst a re-prompt on a rotating-refresh-token IdP, never a torn or forged credential), but the fixes are cheap and the comments overstated the guarantees. sweepStaleTempFiles now skips names containing ".lock.": a steal captures a stale lock by renaming it to .lock..tmp, and ATOMIC_MOVE preserves the stale mtime, so a concurrent save's temp sweep would judge the in-flight capture old and delete it - destroying a lock the stealer may be about to restore to its live owner. stealIfStale now reclaims an empty/unstamped lock after a short grace (EMPTY_LOCK_STEAL_GRACE_MILLIS) instead of the full staleness window. A holder that crashes between the exclusive create and the stamp leaves an empty lock with a fresh mtime that the staleness check protected for the whole window, wedging peers into lock-free refreshes. The grace dwarfs the create-to-stamp gap, so a peer mid-stamp is never pre-empted (which would steal a lock its rightful owner is about to hold). isStale becomes the parameterized isOlderThan. The restore branch now documents honestly that a multi-actor race can momentarily admit two holders - inherent to stealing with a lock file, since a filesystem offers no atomic compare-and-delete. The comments in FileTokenStore, OidcDeviceAuth, and the design doc claimed the under-lock refresh hold is bounded by 4x httpTimeoutMillis; they now note the connection phase (DNS + TCP connect + TLS handshake) is not bounded by httpTimeoutMillis, so lockStaleMillis must clear that on top of the refresh I/O. The default 600s window already absorbs it. Adds regression tests for the empty-lock grace steal and the sweep-skips-capture behaviour; both fail on the unfixed code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 82 ++++++++++++++----- .../client/cutlass/auth/OidcDeviceAuth.java | 34 +++++--- .../test/cutlass/auth/FileTokenStoreTest.java | 53 ++++++++++++ design/oidc-token-persistence.md | 26 ++++-- 4 files changed, 158 insertions(+), 37 deletions(-) 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 index f58d19265..450a854b3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -91,14 +91,26 @@ public final class FileTokenStore implements TokenStore { // 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, so the worst-case hold is ~480s and this 10-minute window stays safely above it + // 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, degrading (best-effort) to a re-prompt on a rotating-refresh-token IdP 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-------")); private static final char[] HEX = "0123456789abcdef".toCharArray(); @@ -141,14 +153,20 @@ public FileTokenStore(Path directory) { * 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: one - * refresh under the lock 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). Set it below that and a peer can steal a - * live holder's 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 (safely above the ~480s worst case). + * 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) { @@ -591,7 +609,7 @@ private static boolean writeLockHolder(Path lock, String nonce) { // stamp the lock with the owner nonce. Unlike the staleness mtime (which only needs to be recent), // this content is what releaseLock checks before deleting, so it must be written reliably; report a // failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release. - // Writing also refreshes the mtime, which is what isStale reads + // Writing also refreshes the mtime, which is what the staleness age check reads try { Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); return true; @@ -663,10 +681,10 @@ private void ensureDirectory() throws IOException { } } - private boolean isStale(Path lock) { + private boolean isOlderThan(Path lock, long thresholdMillis) { try { FileTime modified = Files.getLastModifiedTime(lock); - return System.currentTimeMillis() - modified.toMillis() > lockStaleMillis; + return System.currentTimeMillis() - modified.toMillis() > thresholdMillis; } catch (IOException e) { return false; // cannot determine the age; do not steal } @@ -685,16 +703,27 @@ private void stealIfStale(Path lock) { // 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 isStale->delete gap to the gap between the two renames. + // 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 staleness: if a peer replaces the stale lock with a fresh one in - // between, isStale reads the fresh mtime and returns false, so we never proceed against a live lock - if (!isStale(lock)) { + // 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, Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS, lockStaleMillis))) { + // an empty/unreadable lock is never a validly-held lock (a holder stamps right after creating): it + // is a peer mid-create/stamp (recovers on its own in microseconds) or one a crash orphaned in that + // gap. 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 grace dwarfs the create->stamp gap, so a + // peer mid-stamp is never pre-empted. The capture-verify below still confirms the lock is unchanged + // before completing the steal. return; } final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp"); @@ -722,8 +751,13 @@ private void stealIfStale(Path lock) { // we captured a live lock a peer recreated in the gap: put it back rather than steal it. Use a plain // move (not ATOMIC_MOVE, which maps to rename(2) and would replace the target): if a third party // claimed the now-free path during our capture window, FileAlreadyExistsException leaves their lock - // intact and we drop our captured copy rather than clobber it; a stray .tmp is reclaimed by - // sweepStaleTempFiles on a later save. + // intact and we drop our captured copy rather than clobber it. That drop loses the recreating peer's + // lock file while it still believes it holds the lock, so for that one refresh two holders can run + // concurrently - the inherent residual of stealing with a lock file: a filesystem has no atomic + // "delete/rename only if the content is still X", so the capture-verify shrinks the window to this + // multi-actor race (our steal, a peer recreating, AND a third party claiming the freed path, all + // overlapping) but cannot close it. Best-effort by design: it degrades to one extra refresh - a + // re-prompt on a rotating-refresh-token IdP - never a torn or forged credential (Layer 1 still holds). try { Files.move(captured, lock); } catch (IOException e) { @@ -740,6 +774,16 @@ private void sweepStaleTempFiles(String hashPrefix) { 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() > lockStaleMillis) { Files.deleteIfExists(tmp); 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 index a1047bf4b..b5034f34e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -132,10 +132,12 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 worst case a coordinated refresh can hold a FileTokenStore cross-process lock, as a multiple of - // httpTimeoutMillis: one refresh under the lock runs send + await + parse, plus a body drain on a parse - // failure, each separately bounded by httpTimeoutMillis. build() rejects a FileTokenStore whose - // lock-staleness window does not exceed this, so a peer never judges a live holder's lock stale mid-refresh + // 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. NOTE this multiple does NOT cover the connection phase that precedes the send - DNS + // resolution, the TCP connect, and the TLS handshake are NOT bounded by httpTimeoutMillis (the OS bounds the + // connect instead). build() requires the FileTokenStore staleness window to exceed this multiple as a floor; + // the default window adds ample headroom for a typical connection stall on top of it (see build()) private static final int LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE = 4; // 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 @@ -143,10 +145,12 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 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) safely - // shorter than that store's lock-staleness window, so a slow refresh's live lock is not stolen by a peer + // 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 (DNS + TCP connect + TLS) is bounded by the OS, not by this, and the default staleness window + // leaves headroom for it (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 @@ -1585,11 +1589,15 @@ public OidcDeviceAuth build() { 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 (the refresh under the lock is - // bounded by httpTimeoutMillis, up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE times it), or a peer could steal - // a live holder's lock mid-refresh and reopen the rotating-refresh-token race the lock prevents. The - // store cannot see this client's timeout, so enforce the invariant here, where both are known, rather - // than leave the caller to size it by hand. A non-coordinating TokenStore is exempt - it takes no lock. + // 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. This is a FLOOR, not the whole story - the + // connection phase (DNS + TCP connect + TLS) that precedes the send is bounded by the OS, not by + // httpTimeoutMillis, so the staleness window must also clear a connection stall on top of this floor. + // The default 600s window leaves ~120s of headroom over the floor even at the 120s timeout cap, which + // covers a typical connection stall; a caller raising httpTimeoutMillis should raise lockStaleMillis + // to keep that headroom. 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(); 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 index 0b42e4720..85c9be3af 100644 --- 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 @@ -306,6 +306,36 @@ public void testEmptyFileReturnsNull() throws Exception { }); } + @Test + public void testEmptyLockStolenAfterGraceWithinStaleWindow() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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 testEnsureDirectoryTightensPreExistingDirPerms() throws Exception { Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); @@ -833,6 +863,29 @@ public void testStaleTempFilesAreSweptOnSave() throws Exception { }); } + @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(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index b4d66a1dc..14ac4b943 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -379,11 +379,27 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i acquire budget (~3s); if it still cannot be acquired, **proceed without it** (degrade to Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned and stolen, so a crashed holder cannot wedge others. The window - must dominate the worst-case time a live holder can hold the lock: the refresh under the - lock runs send + await + parse, plus a body drain on a parse failure, each separately - bounded by the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s — never the - interactive wait, which is not held under the lock. 10 minutes stays safely above that - ~480s. + must dominate the worst-case time a live holder can hold the lock. That worst case has two + parts: the refresh I/O under the lock — send + await + parse, plus a body drain on a parse + failure, each separately bounded by the HTTP timeout (capped at 120s), so up to ~4×120s = + ~480s — **plus the connection phase that precedes the send** — DNS resolution, the TCP + connect, and the TLS handshake — which is **not** bounded by the HTTP timeout (the OS bounds + the connect instead; a black-holed connect can run to the OS TCP-connect timeout, commonly + ~2 minutes). So size the window above ~4×HTTP-timeout **plus a generous connection-stall + allowance**, never just ~4×HTTP-timeout; the interactive wait is never held under the lock. + 10 minutes clears ~480s with ample headroom for a typical connection stall; a client that + raises the HTTP timeout must raise this window in step. A client MUST NOT advertise a tighter + guarantee than this (an earlier draft claimed ~480s alone, omitting the connection phase). +- **An empty/unstamped lock is reclaimable on a short grace, not the full staleness window.** + Acquire is exclusive-create followed by a separate stamp write, so a holder that crashes + between the two leaves an empty `.lock` whose mtime is fresh — which the staleness check + would protect for the whole window, wedging peers into lock-free refreshes. Treat a lock that + carries no readable owner stamp as stealable once it is older than a short grace (a few + seconds) instead of the full window. The grace MUST comfortably exceed the create→stamp gap + (microseconds) so a peer momentarily between its create and its stamp is never pre-empted — + pre-empting it would steal a lock its rightful owner is about to hold and force that owner to + degrade. The capture-then-verify steal below still aborts if the lock acquires a stamp in the + gap. The Python client MUST mirror this empty-lock grace. - **Release verifies ownership.** A holder releases by re-reading the lock and deleting it **only when it still carries that holder's own owner stamp**, never by bare path. Should a hold ever outrun the staleness window and be stolen and recreated by a peer, the From a454ac0defb9ab8a06dfc4411c466ce8060fc9b4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 03:04:55 +0100 Subject: [PATCH 067/192] Fix OIDC refresh NPE and harden display safety Address three moderate findings from the device-flow review. adopt() no longer nulls a live in-memory refresh token when a re-read store entry carries a valid served token but no refresh_token (a cross-language peer that never received one, or a tampered file). Nulling it made tryRefresh() call urlEncode(null) and throw an uncaught NullPointerException that aborted getToken()/signIn() instead of degrading to a refresh or an interactive sign-in. adopt() now keeps the current refresh token and tracks what the file actually carried; tryRefresh() also guards against a null refresh token defensively. DisplaySafe.isDisplaySafe() now rejects U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, which are neither ISO control nor Cf format chars yet break a rendered log line in ECMAScript/GUI/JSON log consumers. One classifier change closes the gap across every sanitizer that shares it (sanitizeForDisplay, OidcAuthException.putSanitized, and Utf16Sink.putAsPrintable). The httpTokenProvider Javadoc now documents that a failed HTTP flush preserves the buffered request and its baked token and re-sends it verbatim on retry rather than re-pulling, so a flush that keeps failing until the pulled token expires is then rejected (for example a 401) until the caller discards the buffered rows. Add DisplaySafeTest coverage for U+2028/U+2029 and an OidcDeviceAuthPersistenceTest regression that reproduces the refresh NPE via a peer entry omitting the refresh token (verified both ways). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 12 +++++-- .../client/cutlass/auth/OidcDeviceAuth.java | 21 +++++++++++-- .../questdb/client/std/str/DisplaySafe.java | 10 ++++-- .../auth/OidcDeviceAuthPersistenceTest.java | 31 +++++++++++++++++++ .../client/test/std/str/DisplaySafeTest.java | 13 ++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index dfdfc68e1..8257eb33e 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2026,8 +2026,16 @@ public LineSenderBuilder httpToken(String token) { * refreshed token is presented each time the link is (re)established; an already-established WebSocket * is not re-authenticated mid-stream. The two transports differ on a sustained token outage: over HTTP * a failed pull is retried on the next row, but over WebSocket a pull that keeps failing past the - * reconnect budget terminates the sender for good, like any persistent reconnect failure. A - * lazily-signing-in provider can therefore be wired before the interactive sign-in completes over HTTP, + * reconnect budget terminates the sender for good, like any persistent reconnect failure. + *
    + * 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; over WebSocket a token must already be obtainable * when {@code build()} runs, since the initial handshake pulls it - otherwise that {@code build()} (or, * over HTTP, the first row) fails. Running on the send/flush and reconnect paths, the provider must 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 index b5034f34e..2b79c79d4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1037,7 +1037,15 @@ private boolean adopt(PersistedToken token) { } accessToken = token.getAccessToken(); idToken = token.getIdToken(); - refreshToken = token.getRefreshToken(); + // keep the current refresh token when the file carries none, mirroring storeTokens(). 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. + String fileRefreshToken = token.getRefreshToken(); + 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 @@ -1054,8 +1062,10 @@ private boolean adopt(PersistedToken token) { // half the lifetime); for a legitimate file the two already agree, and the expiry clamp bounds this to // [0, maxLife] tokenTtlMillis = Math.max(0L, expiresAtMillis - now); - // it is already on disk, so a later non-rotating refresh must not rewrite the file - lastPersistedRefreshToken = refreshToken; + // 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; } @@ -1463,6 +1473,11 @@ private void throwIfClosed() { } 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); 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 index 2577d1659..c22c6b11d 100644 --- a/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java +++ b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java @@ -42,7 +42,8 @@ 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) or a surrogate (a lone half, with no displayable meaning). + * 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, @@ -54,7 +55,12 @@ public static boolean isDisplaySafe(int cp) { return false; } final int type = Character.getType(cp); - if (type == Character.FORMAT || type == Character.SURROGATE) { + // 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 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 index 21d8d336e..02ddfebc5 100644 --- 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 @@ -310,6 +310,37 @@ public void testRefreshUnderLockAdoptsPeerTokenAndSkipsNetwork() throws Exceptio }); } + @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 testRestartRefreshesExpiredTokenSkippingDeviceFlow() throws Exception { assertMemoryLeak(() -> { diff --git a/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java b/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java index c65b6d49f..b8e5c75b0 100644 --- a/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java +++ b/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java @@ -71,6 +71,19 @@ public void testFormatBidiAndBomAreUnsafe() { } } + @Test + public void testLineAndParagraphSeparatorsAreUnsafe() { + // U+2028 LINE SEPARATOR (Zl) and U+2029 PARAGRAPH SEPARATOR (Zp) are Unicode line breaks that split a + // rendered log line in ECMAScript/GUI/JSON log consumers, yet are neither ISO control nor Cf format, + // so a tampered field could otherwise forge an apparent extra log line + int[] unsafe = {0x2028, 0x2029}; + for (int cp : unsafe) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertTrue("separator " + hex + " must be unsafe", DisplaySafe.isUnsafeForDisplay(cp)); + Assert.assertFalse("separator " + hex + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + } + } + @Test public void testLoneSurrogatesAreUnsafe() { // a lone surrogate half has no displayable meaning; the code-point classifier must reject it From 2ce1ca02892d8d98cdb83df9b8f20620b0b7fc9e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 10:52:53 +0100 Subject: [PATCH 068/192] Reject OIDC endpoint fragment, harden token lock Endpoint.parse now rejects an endpoint URL carrying a fragment (#...). pathOnly() stripped the fragment before the issuer-path pin while postForm sent endpoint.path verbatim on the wire, so a "#/../other/token" payload from a tampered /settings could steer the credential POST to a path the pin never validated on a server that normalizes '..'. Rejecting the fragment makes the validated path and the sent path identical. The token-store lock's stamp write and its stamp-failure cleanup now verify ownership the way release and steal already do. writeLockHolder refuses to overwrite a lock a peer stamped while this holder was pre-empted in the create->stamp gap (a long pause or cross-machine clock skew wider than the empty-lock grace), and the cleanup drops only a still-empty lock, never a peer's live one by bare path. The residual degrades to a re-prompt on a rotating refresh token, never a torn or forged credential; the design doc and the overstated grace comment are corrected to say so. The on-disk lock protocol is unchanged. Add end-to-end tests proving the httpTokenProvider token reaches the Authorization: Bearer header on the wire and rotates per request, that a failed flush re-sends the same baked token without re-pulling, and that adopt() re-saves a kept refresh token the file omitted. The endpoint fragment and re-save guards are verified both ways. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 33 +++++++-- .../client/cutlass/auth/OidcDeviceAuth.java | 8 +++ .../auth/OidcDeviceAuthPersistenceTest.java | 35 +++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 6 ++ .../line/LineHttpSenderTokenProviderTest.java | 72 +++++++++++++++++-- design/oidc-token-persistence.md | 18 +++-- 6 files changed, 157 insertions(+), 15 deletions(-) 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 index 450a854b3..e134447d6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -611,6 +611,17 @@ private static boolean writeLockHolder(Path lock, String nonce) { // failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release. // Writing also refreshes the mtime, which is what the staleness age check reads try { + // acquireLock created this lock empty; if it already carries a stamp, a peer judged it stale and + // stole+restamped it in the create->stamp gap (a long GC/suspend pause between the two syscalls, or + // a cross-machine clock skew wider than the empty-lock grace). A plain WRITE|TRUNCATE_EXISTING has no + // exclusivity and would overwrite the peer's stamp, leaving two processes each believing they hold + // the lock. Refuse instead - honouring releaseLock's own-stamp ownership rule - so acquireLock + // degrades to a lock-free refresh (the documented best-effort residual) rather than clobber a live + // peer's stamp. A readLockHolder that throws (our file was moved away during the peer's steal) is + // caught below and likewise fails the stamp. + if (readLockHolder(lock) != null) { + return false; + } Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); return true; } catch (Exception e) { @@ -632,11 +643,16 @@ private String acquireLock(Path lock) { } // the exclusive create won the lock but the owner nonce could not be stamped, so releaseLock // could not later prove ownership and would risk deleting a peer's lock; drop the file we just - // created and degrade to a lock-free refresh rather than hold an unverifiable lock + // created and degrade to a lock-free refresh rather than hold an unverifiable lock. Remove it + // only while it is still the empty file we created: writeLockHolder also returns false when a + // peer stole and restamped this path in the create->stamp gap, and deleting that peer's non-empty + // live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule). try { - Files.deleteIfExists(lock); + if (Files.size(lock) == 0) { + Files.deleteIfExists(lock); + } } catch (IOException ignore) { - // another acquirer may have removed it; the next createLockFile settles the race + // gone (a peer moved it during its steal) or unreadable; another acquirer settles the race } return null; } catch (FileAlreadyExistsException e) { @@ -721,9 +737,14 @@ private void stealIfStale(Path lock) { // an empty/unreadable lock is never a validly-held lock (a holder stamps right after creating): it // is a peer mid-create/stamp (recovers on its own in microseconds) or one a crash orphaned in that // gap. 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 grace dwarfs the create->stamp gap, so a - // peer mid-stamp is never pre-empted. The capture-verify below still confirms the lock is unchanged - // before completing the steal. + // orphan stops wedging peers for the whole window. The grace normally dwarfs the create->stamp gap, + // but a pause wider than the grace (a long GC/safepoint or a suspend landing between the two + // syscalls) or a cross-machine clock skew (isOlderThan compares the local clock to the file mtime) + // can still make a freshly-created empty lock look stale and pre-empt a peer mid-stamp. That never + // forges or tears a credential - Layer-1 atomic rename holds, and the pre-empted peer's + // writeLockHolder refuses to overwrite this stamp - it degrades to a concurrent refresh (a re-prompt + // on a rotating-refresh-token IdP), the same best-effort residual inLock already accepts. The + // capture-verify below still confirms the lock is unchanged before completing the steal. return; } final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp"); 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 index 2b79c79d4..74ca755f5 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1928,6 +1928,14 @@ static Endpoint parse(String url) { } 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(']'); + } int schemeEnd = url.indexOf("://"); if (schemeEnd < 0) { throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']'); 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 index 02ddfebc5..bd29a5ee0 100644 --- 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 @@ -341,6 +341,41 @@ public void testRefreshUnderLockKeepsLiveRefreshTokenWhenPeerEntryOmitsIt() thro }); } + @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(() -> { 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 index d01c45cdf..fdc3c8c17 100644 --- 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 @@ -1052,6 +1052,12 @@ public void testEndpointParseRejectsMalformedUrls() { 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"); } @Test(timeout = 30_000) 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 index 9a07c7495..c37ee9b76 100644 --- 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 @@ -27,9 +27,11 @@ import io.questdb.client.HttpTokenProvider; 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 java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -42,10 +44,12 @@ * {@code .httpTokenProvider(auth::getToken)} - be wired before the interactive sign-in * has completed. *

    - * An explicit {@code protocol_version} keeps {@link Sender.LineSenderBuilder#build()} from probing - * the server, and auto-flush is disabled, so rows can be buffered against a port nobody listens on - * without ever opening a connection. Each test runs under {@code assertMemoryLeak} so the sender's - * native buffers are proven freed on close. + * 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 { @@ -98,6 +102,38 @@ public void testControlOrNonAsciiProviderTokenIsRejected() throws Exception { }); } + @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 public void testNullOrEmptyProviderTokenIsRejected() throws Exception { assertMemoryLeak(() -> { @@ -136,6 +172,34 @@ public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() throws Except }); } + @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") diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 14ac4b943..754250195 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -395,11 +395,19 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i between the two leaves an empty `.lock` whose mtime is fresh — which the staleness check would protect for the whole window, wedging peers into lock-free refreshes. Treat a lock that carries no readable owner stamp as stealable once it is older than a short grace (a few - seconds) instead of the full window. The grace MUST comfortably exceed the create→stamp gap - (microseconds) so a peer momentarily between its create and its stamp is never pre-empted — - pre-empting it would steal a lock its rightful owner is about to hold and force that owner to - degrade. The capture-then-verify steal below still aborts if the lock acquires a stamp in the - gap. The Python client MUST mirror this empty-lock grace. + seconds) instead of the full window. The grace normally dwarfs the create→stamp gap + (microseconds), but cannot be guaranteed to: a pause wider than the grace (a long GC/safepoint + or a process suspend landing between the two syscalls) or a cross-machine clock skew (the age + check compares the local clock against the file's mtime) can make a freshly-created empty lock + look stale and let a peer pre-empt a holder mid-stamp. This never forges or tears a credential — + Layer 1's atomic replacement always holds — it degrades to a concurrent refresh (a re-prompt on + a rotating-refresh-token IdP), the same best-effort residual as running lock-free. To keep that + residual bounded, the **stamp write and the stamp-failure cleanup verify ownership** the way + release does: a holder stamps only a lock still empty (never overwriting a stamp a peer wrote + while the holder was pre-empted), and on a stamp failure drops only a lock still empty (never a + peer's non-empty live lock by bare path). The capture-then-verify steal below still aborts if + the lock acquires a stamp in the gap. The Python client MUST mirror this empty-lock grace, and + SHOULD mirror the ownership-verified stamp write. - **Release verifies ownership.** A holder releases by re-reading the lock and deleting it **only when it still carries that holder's own owner stamp**, never by bare path. Should a hold ever outrun the staleness window and be stolen and recreated by a peer, the From 9e0a7565e5967456d019691361a321751ee7e5b8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 11:58:45 +0100 Subject: [PATCH 069/192] Fix WS per-endpoint token pull and getToken doc QwpWebSocketSender.buildAndConnect resolved the Authorization header inside its per-endpoint walk, so a token provider was queried once per endpoint per reconnect round. A throwing provider (a failed silent refresh, or not signed in) was then caught as a per-endpoint transport error, retried across every endpoint, and surfaced as "all endpoints unreachable" - masking the real auth failure and re-hammering the token endpoint with the same dead credential. Resolve the header once per (re)connect round, before the endpoint walk, and reuse it across a failover (a token is cluster-wide). A provider throw now propagates to connectWithRetry, which retries it within the reconnect budget - the documented streaming model - and surfaces the provider's own message. A close that races the round aborts before the possibly blocking token pull. This mirrors QwpQueryClient, which likewise resolves the credential once before its walk. Add a WebSocketTokenProviderTest case that proves the provider is queried once (not per endpoint) and its error surfaces instead of "all endpoints unreachable"; it fails against the pre-fix code. Also correct the OidcDeviceAuth.getToken() javadoc: the silent refresh is not bounded by httpTimeoutMillis end to end. That timeout bounds the send, response wait and body parse, but the preceding connection phase (DNS, TCP connect, TLS handshake) is bounded by the OS, so an unreachable token endpoint can stall the refresh for the OS TCP-connect timeout (~2 minutes), not 30s. The class already documented this for the token-store lock; the getToken() contract now matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 21 ++++++---- .../qwp/client/QwpWebSocketSender.java | 29 +++++++++---- .../client/WebSocketTokenProviderTest.java | 42 +++++++++++++++++++ 3 files changed, 77 insertions(+), 15 deletions(-) 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 index 74ca755f5..a9c744d21 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -87,8 +87,9 @@ * 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, - * bounded by {@link Builder#httpTimeoutMillis(int)} plus, with a coordinating {@link TokenStore}, a brief - * cross-process lock wait - see {@link #getToken()}). To abort a waiting sign-in, call + * each HTTP round-trip phase bounded by {@link Builder#httpTimeoutMillis(int)} - though an unreachable + * endpoint's connect is bounded by the OS, not by it - 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 @@ -468,12 +469,16 @@ public String getAuthorizationHeaderValue() { * 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 is not, however, instantaneous - when the cached - * token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by - * {@link Builder#httpTimeoutMillis(int)} (30s by default); when a {@link TokenStore} coordinates the - * refresh across processes it may first wait briefly to acquire the store's per-identity lock (a few - * seconds at most for {@link FileTokenStore}, then it proceeds without the lock) before that round-trip. - * That is the "quick silent refresh" the {@code HttpTokenProvider} contract permits on the flush path, - * not an unbounded interactive wait. + * token has expired it makes one synchronous refresh round-trip to the token endpoint (and, with a + * coordinating {@link TokenStore}, may first wait briefly to acquire the store's per-identity lock - a few + * seconds at most for {@link FileTokenStore}, then it proceeds without the lock - before that round-trip). + * 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 + * resolution, the TCP connect and the TLS handshake - is bounded by the OS, not by httpTimeoutMillis, so an + * unreachable (black-holed) token endpoint can stall this refresh for the OS TCP-connect timeout (commonly + * ~2 minutes on Linux) rather than 30s. That is the "quick silent refresh" the {@code HttpTokenProvider} + * contract permits on the flush path, not an unbounded interactive wait - but a producer sizing backpressure + * against this call should expect that OS-bounded connect stall, not a hard 30s cap. * * @return a non-null, non-empty token * @throws OidcAuthException if no token has been obtained yet, if the cached token expired and could 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 54656c3ee..3f1ed25e5 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 @@ -139,8 +139,9 @@ public class QwpWebSocketSender implements Sender { // 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 inside buildAndConnect's per-endpoint try, so a throwing - // provider (e.g. a failed silent refresh) is handled as a connect failure rather than escaping. + // when no auth is configured. Evaluated once per (re)connect round in buildAndConnect, before the + // endpoint walk (not once per endpoint); a throwing provider propagates to the connectWithRetry + // reconnect wrapper, which retries it within the reconnect budget and surfaces the provider's message. private final Supplier authorizationHeaderSupplier; private final int autoFlushBytes; private final long autoFlushIntervalNanos; @@ -2433,6 +2434,22 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) { 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. + if (cursorSendLoop == null ? closed : !cursorSendLoop.isRunning()) { + throw new LineSenderException("sender closed during connect"); + } + // 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 propagates to the connectWithRetry reconnect wrapper, which retries it + // within the reconnect budget like any other connect failure and surfaces the provider's own message, + // so a transient failed refresh recovers and only a persistent one terminates the sender (this + // transport's documented reconnect model). Mirrors QwpQueryClient, which likewise resolves the + // credential once before its endpoint walk. + final String authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get(); while (true) { if (cursorSendLoop == null ? closed : !cursorSendLoop.isRunning()) { throw new LineSenderException("sender closed during connect"); @@ -2451,11 +2468,9 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) { newClient.setQwpRequestDurableAck(requestDurableAck); newClient.connect(ep.host, ep.port); int upgradeTimeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); - // Pull the current Authorization header for this handshake. For an httpTokenProvider - // this re-queries the provider, so a reconnect presents a freshly refreshed token. A - // provider that throws here (a failed silent refresh) is caught below as a connect - // failure for this endpoint and retried within the reconnect budget. - String authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get(); + // 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); } catch (HttpClientException e) { HttpClientException classified = QwpUpgradeFailures.classify(newClient, ep.host, ep.port, e); 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 index cb8b8ef4e..1b7dfaee5 100644 --- 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 @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client; import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.OidcAuthException; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import org.junit.Assert; import org.junit.Test; @@ -135,6 +136,47 @@ public void testStaticTokenStillSuppliedOverWebSocket() throws Exception { }); } + @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 public void testUsernamePasswordStillSuppliedOverWebSocket() throws Exception { assertMemoryLeak(() -> { From 1d671638ca12b1718c620a73f27a2a03752a6ea0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 12:27:45 +0100 Subject: [PATCH 070/192] Reject non-ASCII OIDC hosts and fix review nits A review flagged several minor issues; this commit addresses the actionable ones. Endpoint.parse now rejects a non-ASCII host. String.equalsIgnoreCase folds several non-ASCII letters onto ASCII (U+0130 -> i, U+212A -> k, ...), so a homoglyph host advertised by a tampered /settings could otherwise pass the origin pin against a pinned issuer; a non-ASCII host would not resolve anyway (the HTTP layer sends it to the OS resolver as raw UTF-8, no IDNA). Endpoint.parse also lower-cases the URL scheme before matching so a case-insensitive HTTPS/Http (RFC 3986) builds, matching the browser launcher; toLowerCase folds only ASCII, so a homoglyph scheme stays rejected. Renamed sameOrigin to isSameOrigin per the is/has convention. FileTokenStore.save now retries the atomic rename on a transient Windows AccessDeniedException (a sharing violation from a concurrent reader), so a routine read/write overlap does not needlessly degrade best-effort persistence. POSIX rename over an open file is unaffected. Smaller nits: DirectUtf8Sink.put(byte[],int,int) asserts its range; AbstractLineHttpSender.close() nulls jsonErrorParser after freeing it; Utf16Sink.putAsPrintable skips a redundant charAt on the BMP fast path; and a duplicated comment in TokenStoreKey is removed. Adds OidcDeviceAuthTest cases: a non-ASCII host and an uppercase scheme (both proven to fail against the pre-fix code), and the issuer-pin accept path for host casing and an implicit vs explicit 443 port. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 39 +++++++++++++++--- .../client/cutlass/auth/OidcDeviceAuth.java | 40 +++++++++++++------ .../client/cutlass/auth/TokenStoreKey.java | 2 - .../line/http/AbstractLineHttpSender.java | 2 +- .../client/std/str/DirectUtf8Sink.java | 1 + .../io/questdb/client/std/str/Utf16Sink.java | 7 +++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 39 ++++++++++++++++++ 7 files changed, 107 insertions(+), 23 deletions(-) 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 index e134447d6..aaafab8f4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -39,6 +39,7 @@ 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; @@ -130,6 +131,12 @@ public final class FileTokenStore implements TokenStore { // attacker-writable directory as the token file, and a real owner stamp (pid@host + millis + UUID) is a // few hundred 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; + // 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 once @@ -286,12 +293,7 @@ public void save(TokenStoreKey key, PersistedToken token) { boolean moved = false; try { writeAndFlush(tmp, content); - try { - Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException e) { - // a rare filesystem without atomic rename; a plain replace still beats a partial write - Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); - } + replaceTarget(tmp, target); moved = true; } finally { if (!moved) { @@ -536,6 +538,31 @@ private static void releaseLock(Path lock, String nonce) { } } + 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; + } + private static void restrictToOwner(Path directory) { // best-effort: the at-rest protection of the plaintext token files is exactly these owner-only // directory permissions, so re-tighten a pre-existing directory another tool/umask left loose rather 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 index a9c744d21..d30043bc5 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -360,10 +360,10 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt // still applies to every endpoint, enforced by validateEndpointOrigins in build(). if (resolvedIssuer != null) { Endpoint pin = Endpoint.parse(resolvedIssuer); - if (tokenEndpointFromSettings && !sameOrigin(Endpoint.parse(tokenEndpoint), pin)) { + if (tokenEndpointFromSettings && !isSameOrigin(Endpoint.parse(tokenEndpoint), pin)) { throw endpointOriginNotPinned("token endpoint", tokenEndpoint, originOf(pin)); } - if (deviceEndpointFromSettings && !sameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) { + if (deviceEndpointFromSettings && !isSameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) { throw endpointOriginNotPinned("device authorization endpoint", deviceAuthorizationEndpoint, originOf(pin)); } } @@ -810,6 +810,14 @@ private static boolean isLoopbackHost(String host) { return host != null && (host.equalsIgnoreCase("localhost") || (host.startsWith("127.") && isDottedIpv4(host))); } + 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; } @@ -917,12 +925,6 @@ private static void requireSecureTransport(boolean isTls, String label, String u } } - private static boolean sameOrigin(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 - return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host); - } - private static String sanitizeForDisplay(String value) { if (value == null) { return null; @@ -984,20 +986,20 @@ private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint dev // 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 (!sameOrigin(tokenEndpoint, deviceAuthorizationEndpoint)) { + 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 (!sameOrigin(tokenEndpoint, issuer)) { + 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 (!sameOrigin(deviceAuthorizationEndpoint, 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)) @@ -1946,7 +1948,10 @@ static Endpoint parse(String url) { throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']'); } boolean isTls; - String scheme = url.substring(0, schemeEnd); + // 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)) { @@ -2006,6 +2011,17 @@ static Endpoint parse(String url) { 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++) { + if (host.charAt(i) > 0x7f) { + throw new OidcAuthException().put("invalid url, the host contains a non-ASCII character [url=").put(url).put(']'); + } + } return new Endpoint(host, port, path, isTls); } } 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 index f93bb69ae..d8498c7c1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java @@ -70,8 +70,6 @@ public TokenStoreKey( 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 // 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) { 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 188ba9ecb..5d5b71d01 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 @@ -475,7 +475,7 @@ public void close() { flush0(true); } } finally { - Misc.free(jsonErrorParser); + jsonErrorParser = Misc.free(jsonErrorParser); closed = true; client = Misc.free(client); } 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 1095b03d2..a453a42dc 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 @@ -110,6 +110,7 @@ public DirectUtf8Sink put(byte b) { * that rely on {@link #isAscii()} should not use this overload for ascii-only content. */ public DirectUtf8Sink put(byte[] src, int lo, int hi) { + assert lo >= 0 && hi <= src.length && lo <= hi : "put(byte[]) range out of bounds"; final int len = hi - lo; if (len > 0) { setAscii(false); 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 e824c6920..44412e86c 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 @@ -52,8 +52,11 @@ default void putAsPrintable(CharSequence nonPrintable) { final int cp = Character.codePointAt(nonPrintable, i); final int count = Character.charCount(cp); if (DisplaySafe.isDisplaySafe(cp)) { - for (int j = 0; j < count; j++) { - put(nonPrintable.charAt(i + j)); + 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); 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 index fdc3c8c17..7231656f2 100644 --- 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 @@ -213,6 +213,23 @@ public void testAudienceSentOnRefresh() throws Exception { }); } + @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(() -> { @@ -1024,6 +1041,23 @@ public void testEndpointParseRejectsDisplayUnsafeUrl() { } } + @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 @@ -1058,6 +1092,11 @@ public void testEndpointParseRejectsMalformedUrls() { 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 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 } @Test(timeout = 30_000) From 6ced3524f531183e0dcca83b3f3aebfceda8d382 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 14:41:33 +0100 Subject: [PATCH 071/192] Fix flaky token-store steal-contention test testConcurrentStealContentionPreservesMutualExclusion asserted a hard mutual-exclusion invariant (overlaps == 0, maxInside == 1) that the FileTokenStore cross-process lock does not provide. stealIfStale is best-effort by design and documents a three-actor residual - a peer recreating the lock while a second captures that fresh live lock and a third claims the freed path - under which two holders briefly run at once, degrading only to one extra token refresh (Layer-1 atomic rename keeps the credential intact). Under four-way contention that residual is reachable, so the test failed intermittently on CI. A stress harness confirmed the mechanism: 8 contenders overlapped in 12 of 700 iterations (maxInside 2), while 2 contenders never overlapped in 800 iterations. Split the one over-strict test in two: - testConcurrentStealContentionTwoWayPreservesMutualExclusion runs two contenders and keeps the strict overlaps == 0 / maxInside == 1 assertions. The three-actor residual cannot arise with two threads, so this is deterministic and still pins the exclusion the atomic capture guarantees. - testConcurrentStealContentionDegradesCleanly runs four contenders and asserts only the benign invariants - every contender runs its section and no capture temp leaks - tolerating the documented residual. No production change. Full FileTokenStoreTest is 40/40 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/cutlass/auth/FileTokenStoreTest.java | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) 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 index 85c9be3af..17095035a 100644 --- 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 @@ -185,7 +185,7 @@ public void testClearOnEmptyStoreIsNoOp() throws Exception { } @Test - public void testConcurrentStealContentionPreservesMutualExclusion() throws Exception { + public void testConcurrentStealContentionDegradesCleanly() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); Files.createDirectories(dir); @@ -195,14 +195,64 @@ public void testConcurrentStealContentionPreservesMutualExclusion() throws Excep Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8)); Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000)); - // several "processes" race to steal the one abandoned lock. The staleness window (60s) far exceeds - // each ~100ms hold, so a live holder's lock is never judged stale: only the abandoned lock is - // stealable, and the atomic, stamp-verified steal must admit exactly one holder at a time. This is a - // mutual-exclusion invariant guard under steal contention: it exercises the steal path concurrently - // and fails on any gross loss of exclusion. It does not deterministically reproduce the narrow - // isStale->delete race the atomic steal closes - that needs a timing seam this final class does not - // expose - so the fix itself rests on the atomic capture + stamp re-check, not on this test alone. + // several "processes" race to steal the one abandoned lock. This exercises the steal path under + // N-way contention and asserts it degrades CLEANLY: every contender eventually runs its critical + // section (none is starved or wedged) and no atomic-capture temp file leaks. 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. + // testConcurrentStealContentionTwoWayPreservesMutualExclusion pins the exclusion the atomic capture + // does guarantee, deterministically, with two contenders. final int threads = 4; + AtomicInteger ran = new AtomicInteger(); + TokenStore.CriticalSection section = () -> { + Os.sleep(100); + ran.incrementAndGet(); + return true; + }; + + 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(() -> store.inLock(key, section)); + } + for (Thread t : ts) { + t.start(); + } + for (Thread t : ts) { + t.join(); + } + + Assert.assertEquals("every contender must run its critical section", threads, ran.get()); + assertNoCaptureTempFiles(dir, key); + }); + } + + @Test + public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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 processes race to steal the one abandoned lock. With exactly two contenders the steal is + // deterministically mutually exclusive: the atomic capture (rename) lets exactly one steal the + // abandoned lock, and once a winner holds a freshly-stamped lock the loser reads that live stamp, + // judges it not stale (the 60s window far exceeds each ~100ms hold) and waits rather than stealing + // it; the empty-lock grace likewise stops the loser stealing the winner's lock in its brief + // create->stamp gap. The three-actor residual stealIfStale documents - a peer recreating the lock + // while a second captures it AND a third claims the freed path - structurally cannot arise with two + // threads, so exclusion holds exactly here (the N-way best-effort path is + // testConcurrentStealContentionDegradesCleanly). + final int threads = 2; AtomicInteger inside = new AtomicInteger(); AtomicInteger maxInside = new AtomicInteger(); AtomicInteger overlaps = new AtomicInteger(); From f3e62ed4392b557b8664f80b42645800f14cf3be Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 16:52:31 +0100 Subject: [PATCH 072/192] Address code-review findings on OIDC device flow Follow-up fixes from a review of the OIDC device-flow PR. Security - Endpoint.parse rejects a URL query (?...), matching its existing fragment (#) rejection: pathOnly() strips the query before the issuer-path pin, yet postForm sends endpoint.path verbatim, so a tampered /settings could route an unvalidated query to the IdP. Correctness - tryRefresh falls back to the interactive flow when a refreshed token carries a control/non-ASCII char, instead of letting validateTokenChars propagate past the fallback. - JsonLexer.unescape keeps the backslash on an unknown or malformed escape rather than dropping it, so non-conformant text survives. - DirectUtf8Sink.put(byte[]) range-checks and throws instead of an assert that is a no-op in client apps run without -ea. - FileTokenStore warns once about missing POSIX perms via an AtomicBoolean compareAndSet, closing a benign double-warn race. Performance - AbstractLineHttpSender precomputes the User-Agent header once instead of concatenating it on every newRequest. - OidcDeviceAuth reuses build()'s parsed endpoints in the constructor instead of re-parsing the raw strings. Docs and tests - DeviceCodePrompt javadoc says "display-safe text", not "plain ASCII". - oidc-token-persistence.md reflects the shipped implementation. - Add direct DirectUtf8Sink.put(byte[]) range/bounds tests and a short 1-digit 4xx/5xx status rejection test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/DeviceCodePrompt.java | 2 +- .../client/cutlass/auth/FileTokenStore.java | 9 ++-- .../client/cutlass/auth/OidcDeviceAuth.java | 34 ++++++++++--- .../client/cutlass/json/JsonLexer.java | 11 ++-- .../line/http/AbstractLineHttpSender.java | 6 ++- .../client/std/str/DirectUtf8Sink.java | 7 ++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 50 +++++++++++++++++++ .../test/cutlass/json/JsonLexerTest.java | 11 ++-- .../test/std/str/DirectUtf8SinkTest.java | 46 +++++++++++++++++ design/oidc-token-persistence.md | 18 ++++--- 10 files changed, 165 insertions(+), 29 deletions(-) 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 index 8389c43d1..51914c403 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java @@ -40,7 +40,7 @@ public interface DeviceCodePrompt { /** - * Prints the sign-in instructions to {@code System.out} as plain ASCII, without opening a browser. + * 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 -> { 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 index aaafab8f4..49765c310 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -56,6 +56,7 @@ import java.util.Arrays; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; /** * The default {@link TokenStore}: one plaintext JSON file per identity under a directory, with the @@ -139,8 +140,9 @@ public final class FileTokenStore implements TokenStore { 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 once - private static volatile boolean warnedNoPosixPerms; + // 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 final Path directory; private final long lockAcquireBudgetMillis; private final long lockStaleMillis; @@ -610,10 +612,9 @@ 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) { + if (!warnedNoPosixPerms.compareAndSet(false, true)) { return; } - warnedNoPosixPerms = true; System.err.println("questdb client: 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."); 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 index d30043bc5..d3798958a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -199,13 +199,14 @@ public class OidcDeviceAuth implements QuietCloseable { // 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) { + 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); - this.deviceAuthorizationEndpoint = Endpoint.parse(builder.deviceAuthorizationEndpoint); - this.tokenEndpoint = Endpoint.parse(builder.tokenEndpoint); + // 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; @@ -226,7 +227,7 @@ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { audience != null && !audience.isEmpty() ? audience : null, this.groupsInToken ); - // allocate the native lexer last: an Endpoint.parse above can throw on a malformed url, and + // 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); } @@ -1517,7 +1518,17 @@ private boolean tryRefresh() { && isHttpStatusSuccess() && tokenParser.error.length() == 0; if (hasRequiredToken) { - storeTokens(tokenParser); + try { + storeTokens(tokenParser); + } 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; } // the refresh token expired or was revoked, or did not return the token we need; fall back to the @@ -1631,7 +1642,7 @@ public OidcDeviceAuth build() { .put("), otherwise a slow refresh's live cross-process lock could be stolen by a peer mid-refresh"); } } - return new OidcDeviceAuth(this, tls); + return new OidcDeviceAuth(this, tls, deviceEndpoint, parsedTokenEndpoint); } public Builder clientId(String clientId) { @@ -1943,6 +1954,17 @@ static Endpoint parse(String url) { 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(']'); 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 0c4b0c4cd..0fe38eca3 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 @@ -409,14 +409,17 @@ private CharSequence unescape(CharSequence raw) { unescapeSink.put((char) cp); i += 6; } else { - // malformed unicode escape: drop the backslash, keep the following character - unescapeSink.put(esc); + // 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: - // unknown escape: drop the backslash, keep the escaped character (lenient) - unescapeSink.put(esc); + // unknown escape: keep the backslash and the escaped character verbatim (lenient), so a + // literal backslash in non-conformant input (e.g. a Windows path in an error body) is not + // dropped + unescapeSink.put('\\').put(esc); i += 2; break; } 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 5d5b71d01..a0202cdaa 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 @@ -83,6 +83,7 @@ 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; @@ -203,6 +204,9 @@ protected AbstractLineHttpSender( : HttpClientFactory.newPlainTextInstance(clientConfiguration); } this.questDBVersion = new BuildInformationHolder().getSwVersion(); + // precompute the User-Agent header value once: newRequest() runs on every flush (and twice per flush + // for a token provider), so concatenating it there would allocate a String each time + this.userAgent = "QuestDB/java/" + questDBVersion; this.request = newRequest(); this.maxNameLength = maxNameLength; this.rnd = rnd; @@ -760,7 +764,7 @@ private HttpClient.Request newRequest(boolean pullProviderToken) { 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) { 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 a453a42dc..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 @@ -110,7 +110,12 @@ public DirectUtf8Sink put(byte b) { * that rely on {@link #isAscii()} should not use this overload for ascii-only content. */ public DirectUtf8Sink put(byte[] src, int lo, int hi) { - assert lo >= 0 && hi <= src.length && lo <= hi : "put(byte[]) range out of bounds"; + // 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); 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 index 7231656f2..9d3fab3aa 100644 --- 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 @@ -1092,6 +1092,14 @@ public void testEndpointParseRejectsMalformedUrls() { 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 @@ -3298,6 +3306,48 @@ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception { }); } + @Test + 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 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 74d8d9d5b..ee9505cf1 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 @@ -752,11 +752,12 @@ public void testStringEscapesExoticAndLenient() throws Exception { 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 drops the backslash and keeps the following text rather than failing the parse. These pin - // that behavior and cover the lenient arms that otherwise carry most of the file's coverage: - assertDecodedValue("{\"v\":\"a" + bs + "xb\"}", "axb"); // unknown escape -> drop backslash - assertDecodedValue("{\"v\":\"a" + bs + "uZZZZb\"}", "auZZZZb"); // non-hex unicode escape -> literal - assertDecodedValue("{\"v\":\"ab" + bs + "u12\"}", "abu12"); // too few hex digits -> literal + // 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"); }); diff --git a/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java b/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java index 410f2487e..1e1dbd15e 100644 --- a/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java +++ b/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java @@ -121,6 +121,43 @@ public void testDirectUtf8Sequence() { } } + @Test + public void testPutByteArrayRange() { + try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) { + final byte[] src = "abcdefgh".getBytes(StandardCharsets.UTF_8); + + // a partial range [2, 5) copies exactly "cde" and grows the sink past its initial capacity + sink.put(src, 2, 5); + Assert.assertEquals(3, sink.size()); + TestUtils.assertEquals("cde".getBytes(StandardCharsets.UTF_8), sink); + // the bulk overload sets the ascii hint to false conservatively, even for ascii bytes + Assert.assertFalse(sink.isAscii()); + + // an empty range [3, 3) is a no-op + final int sizeBefore = sink.size(); + sink.put(src, 3, 3); + Assert.assertEquals(sizeBefore, sink.size()); + + // a full range [0, len) appends the whole array + sink.clear(); + sink.put(src, 0, src.length); + Assert.assertEquals(src.length, sink.size()); + TestUtils.assertEquals(src, sink); + } + } + + @Test + public void testPutByteArrayRangeRejectsBadBounds() { + try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) { + final byte[] src = {1, 2, 3}; + // a bad range must throw rather than run an unchecked native copy (asserts are off in client apps) + assertBadRange(sink, src, -1, 2); // lo < 0 + assertBadRange(sink, src, 0, 4); // hi > len + assertBadRange(sink, src, 2, 1); // lo > hi + Assert.assertEquals("a rejected put must not advance the sink", 0, sink.size()); + } + } + @Test public void testPutUtf8Sequence() { try (DirectUtf8Sink sink = new DirectUtf8Sink(1)) { @@ -208,6 +245,15 @@ public void testUtf8Sequence() { } } + private static void assertBadRange(DirectUtf8Sink sink, byte[] src, int lo, int hi) { + try { + sink.put(src, lo, hi); + Assert.fail("expected IndexOutOfBoundsException for lo=" + lo + ", hi=" + hi); + } catch (IndexOutOfBoundsException expected) { + // ok: the public overload range-checks before the native copy + } + } + private static void assertUtf8Encoding(DirectUtf8Sink sink, String s) { sink.clear(); sink.put(s); diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 754250195..30acd21bb 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -1,7 +1,11 @@ # OIDC device-flow token persistence -Status: **draft v1**, follow-on to PR #52 (`OidcDeviceAuth`, RFC 8628 device flow). -Targets branch `ia_oidc_device_flow`. +Status: **implemented** in PR #52 (`OidcDeviceAuth`, RFC 8628 device flow) on branch +`ia_oidc_device_flow` — both the `TokenStore` SPI and the default `FileTokenStore` (Layer 1 +atomic replace and Layer 2 lock-file critical section) shipped together. This document remains +the frozen cross-language on-disk contract (file name, JSON schema, atomic-write and lock-file +protocols) that other clients (e.g. Python) mirror; the design discussion below is retained as +the rationale of record. Code line references are indicative and may drift from the current source. ## Problem @@ -456,11 +460,11 @@ Resolved: - **Frozen on-disk contract** (path, hash, schema, atomic write, lock-file protocol), because the Python client will mirror it. -Still to confirm: -1. **Layer 2 (lock file) now or fast-follow?** Layer 1 (atomic replace) is mandatory and - small; Layer 2 only matters for rotating-refresh-token IdPs. Either way the protocol is - frozen in the spec above. Recommendation: ship both together — the rotating case is - realistic and the lock-file code is modest. +Resolved (shipped in PR #52): +- **Layer 2 (lock file) shipped together with Layer 1.** `FileTokenStore` implements both the + mandatory atomic-replace integrity layer and the `O_CREAT|O_EXCL` lock-file critical section + for rotating-refresh-token IdPs, as recommended — the rotating case is realistic and the + lock-file code is modest. ## Testing strategy From acfa6e6551896ce2f7eec96cac024689ee6d8149 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 17:49:01 +0100 Subject: [PATCH 073/192] Serialize token-store long fields as digits FileTokenStore.putLongMember wrote a long field via sink.put(long), which routes through Numbers.append(..., checkNaN=true) and renders Long.MIN_VALUE as the literal JSON null. The two long fields (expires_at_millis, token_ttl_millis) are present, non-nullable integers, so a bare null there breaks the frozen cross-language format (serialize() omits absent fields rather than writing null, so a null is indistinguishable from absent) and round-trips back to 0 on read via parseLongOrZero -- a silent corruption a Python peer would also diverge on. putLongMember now calls Numbers.append(sink, value, false), which emits the full number, so every long value round-trips verbatim. The path is reachable only through the public FileTokenStore.save(...) SPI; OidcDeviceAuth clamps both fields to a non-negative range before persisting, so the OIDC flow never hits it. Add testLongFieldsSerializeAsDigitsNotBareNull: it saves Long.MIN_VALUE in both long fields, asserts the on-disk JSON carries the digits rather than a bare null, and that load() round-trips them instead of collapsing to 0. The test fails on the pre-fix code and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 7 ++++- .../test/cutlass/auth/FileTokenStoreTest.java | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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 index 49765c310..708f887ee 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -402,7 +402,12 @@ private static void putBooleanMember(StringSink sink, String name, boolean value private static void putLongMember(StringSink sink, String name, long value) { sink.put(','); putName(sink, name); - sink.put(value); + // 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) { 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 index 17095035a..aa1bebdc5 100644 --- 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 @@ -682,6 +682,32 @@ public void testLockFilePermissionsOwnerOnly() throws Exception { }); } + @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(() -> { From 7ba71a77c7bba9a6307c8b5b6b185eddfbbdfd63 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 18:21:33 +0100 Subject: [PATCH 074/192] Add OIDC token-provider tests and cleanups Address minor code-review findings on the OIDC device flow: fill test gaps around the token provider and tidy comments and dead code. No production behavior change beyond simplifying unreachable branches. Tests: - Add testRefreshedTokenWithControlCharFallsBackToInteractiveFlow: a refresh whose 200 response carries a served token with an escaped control char (JsonLexer now decodes it into a real CR) is rejected by validateTokenChars, and tryRefresh must fall back to the interactive flow rather than propagate. Guards the tryRefresh storeTokens catch; verified to fail without it. - Add testThrowingProviderOnReconnectIsRetriedAndRecovers and testPersistentlyThrowingProviderOnReconnectTerminatesTheSender: a token provider that throws on a WebSocket reconnect (the background I/O thread) is retried within the reconnect budget and recovers on a transient failure, or terminates the sender once the budget is exhausted on a persistent one. - Replace the vacuous testMalformedEndpointDoesNotLeakNativeMemory (which fed "not-a-url", rejected before the lexer is allocated) with testRejectedBuildDoesNotLeakNativeMemory (a parseable-but-rejected endpoint) and testSuccessfulBuildAndCloseDoNotLeakNativeMemory - the path that actually allocates and frees the native lexer. The new guard fails if close() stops freeing the lexer. - Add @Test(timeout=30_000) to testShortAllDigitStatusNotTreatedAsTransientOrTerminal so a guard regression fails fast instead of polling to the device-code deadline. Comments and dead code: - Reword the writeLockHolder and acquireLock comments to match releaseLock's honest framing: the read-then-write and size-check-then-delete narrow, but do not close, the peer-stamp clobber window; the residual degrades to a double-refresh, never a torn credential. - Reduce pathOnly() to return Endpoint.parse(url).path and drop the now-unreachable ?/# arms in Endpoint.parse's authority terminator and path construction (Endpoint.parse rejects ? and # up front). - Expand the refreshUnderLock comment: the equals(refreshToken, lastPersistedRefreshToken) guard no longer strictly means "unsaved newer token" once adopt() keeps a live token the file lacked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 18 ++-- .../client/cutlass/auth/OidcDeviceAuth.java | 51 ++++----- .../test/cutlass/auth/OidcDeviceAuthTest.java | 86 +++++++++++++-- .../client/WebSocketTokenProviderTest.java | 101 ++++++++++++++++++ 4 files changed, 210 insertions(+), 46 deletions(-) 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 index 708f887ee..4e058284a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -647,11 +647,14 @@ private static boolean writeLockHolder(Path lock, String nonce) { // acquireLock created this lock empty; if it already carries a stamp, a peer judged it stale and // stole+restamped it in the create->stamp gap (a long GC/suspend pause between the two syscalls, or // a cross-machine clock skew wider than the empty-lock grace). A plain WRITE|TRUNCATE_EXISTING has no - // exclusivity and would overwrite the peer's stamp, leaving two processes each believing they hold - // the lock. Refuse instead - honouring releaseLock's own-stamp ownership rule - so acquireLock - // degrades to a lock-free refresh (the documented best-effort residual) rather than clobber a live - // peer's stamp. A readLockHolder that throws (our file was moved away during the peer's steal) is - // caught below and likewise fails the stamp. + // exclusivity and would overwrite that stamp, leaving two processes each believing they hold the + // lock, so refuse when a stamp is already present - honouring releaseLock's own-stamp ownership rule. + // This read-then-write is two syscalls, so like releaseLock's own read-then-delete it only NARROWS + // the clobber window (a steal landing between the read and the write is still overwritten); it does + // not close it. There is no atomic "write-only-if-still-empty" primitive to close it with. The + // residual degrades to at most the documented double-refresh (a re-prompt on a rotating IdP), never + // a torn or forged credential - Layer-1's atomic rename holds regardless. A readLockHolder that + // throws (our file was moved away during the peer's steal) is caught below and likewise fails the stamp. if (readLockHolder(lock) != null) { return false; } @@ -679,7 +682,10 @@ private String acquireLock(Path lock) { // created and degrade to a lock-free refresh rather than hold an unverifiable lock. Remove it // only while it is still the empty file we created: writeLockHolder also returns false when a // peer stole and restamped this path in the create->stamp gap, and deleting that peer's non-empty - // live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule). + // live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule). Like + // writeLockHolder's read-then-write, this size-check-then-delete is two syscalls, so it narrows + // but does not fully close that window; the residual degrades to the documented double-refresh, + // never a torn credential. try { if (Files.size(lock) == 0) { Files.deleteIfExists(lock); 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 index d3798958a..cba2d01e3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -855,16 +855,10 @@ private static int parseIntOrZero(CharSequence value) { } private static String pathOnly(String url) { - // the path component only (drop any ?query / #fragment); a ;matrix parameter stays part of the path, - // so a traversal hidden in it (.../token;..%2f..) is still scanned - String path = Endpoint.parse(url).path; - for (int i = 0, n = path.length(); i < n; i++) { - char c = path.charAt(i); - if (c == '?' || c == '#') { - return path.substring(0, i); - } - } - return path; + // 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) { @@ -1334,11 +1328,17 @@ private boolean refreshUnderLock() { // 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. If they differ, - // a previous save failed (persistence is best-effort), so the in-memory token is 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. In that case keep the in-memory - // token and refresh with it. + // 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 { @@ -1982,27 +1982,20 @@ static Endpoint parse(String url) { 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 '#'; splitting only on - // '/' (as before) folded a query/fragment - or userinfo - into the host on a path-less url + // 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++) { - char c = url.charAt(i); - if (c == '/' || c == '?' || c == '#') { + if (url.charAt(i) == '/') { authorityEnd = i; break; } } String hostPort = url.substring(hostStart, authorityEnd); - // a path-less url uses '/'; a query/fragment with no path is prefixed with '/' so the request - // line stays well-formed (a '/'-terminated authority already carries its own leading slash) - String path; - if (authorityEnd == url.length()) { - path = "/"; - } else if (url.charAt(authorityEnd) == '/') { - path = url.substring(authorityEnd); - } else { - path = "/" + url.substring(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 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 index 9d3fab3aa..43ac4bf10 100644 --- 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 @@ -2043,24 +2043,49 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc } @Test(timeout = 30_000) - public void testMalformedEndpointDoesNotLeakNativeMemory() { - // build() parses the endpoints up front (for the co-location / issuer-pin checks) and throws on - // this malformed url before the constructor allocates the native JSON lexer, so the never-returned - // instance cannot leak it. Measure the parser tag directly - the module's assertMemoryLeak does not - // flag a single-tag growth. + public void testRejectedBuildDoesNotLeakNativeMemory() { + // 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. Measure the parser tag directly; the module's assertMemoryLeak does not flag a single-tag growth. long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() .clientId("c") - .deviceAuthorizationEndpoint("not-a-url") + .deviceAuthorizationEndpoint("http://idp.example/device") // parses fine, but plaintext http to a non-loopback host .tokenEndpoint("https://idp.example/token") - .allowInsecureTransport(true) + .allowInsecureTransport(false) .build() ) { - Assert.fail("expected Endpoint.parse to reject the malformed url"); + Assert.fail("expected the https requirement to reject the plaintext device endpoint"); } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("expected a scheme")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("use an https url")); } - Assert.assertEquals("the JSON lexer native buffer leaked", + 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() { + // 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. + 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)); } @@ -2502,6 +2527,45 @@ public void testRefreshErrorFallsBackToInteractiveFlow() throws Exception { }); } + @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(() -> { @@ -3306,7 +3370,7 @@ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception { }); } - @Test + @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 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 index 1b7dfaee5..399b20424 100644 --- 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 @@ -177,6 +177,107 @@ public void testThrowingProviderResolvedOncePerConnectRound() throws Exception { }); } + @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 + public void testPersistentlyThrowingProviderOnReconnectTerminatesTheSender() throws Exception { + assertMemoryLeak(() -> { + // The complement to the recover-on-transient case: a provider that keeps throwing on every reconnect + // must NOT be silently swallowed on the background I/O thread - once the (short here) reconnect + // budget is exhausted the sender terminates, and a subsequent send/flush must surface the failure + // rather than block forever or silently drop data. A cap on the reconnect duration keeps this fast. + 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) + .reconnectInitialBackoffMillis(20) + .reconnectMaxBackoffMillis(20) + .reconnectMaxDurationMillis(300) // short budget so persistent failure terminates quickly + .httpTokenProvider(() -> { + int n = calls.incrementAndGet(); + if (n == 1) { + return "TOKEN-1"; // initial connect succeeds + } + throw new OidcAuthException("persistent: not signed in"); // every reconnect pull fails + }) + .build()) { + Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS)); + + // batch 1 lands and is ACKed, then the server drops the socket -> the reconnect keeps failing + sender.table("foo").longColumn("v", 1L).atNow(); + sender.flush(); + + // once the reconnect budget exhausts, the terminated sender must surface the failure on a + // later send/flush (never a silent success), so drive rows until one throws + waitFor(() -> { + try { + sender.table("foo").longColumn("v", 2L).atNow(); + sender.flush(); + return false; // not terminated yet - the reconnect is still within budget + } catch (Exception e) { + return true; // terminated: the persistent provider failure surfaced, as intended + } + }, 15_000); + Assert.assertTrue("the provider must have been re-queried on the failing reconnect, got " + calls.get(), + calls.get() >= 2); + } + } + }); + } + @Test public void testUsernamePasswordStillSuppliedOverWebSocket() throws Exception { assertMemoryLeak(() -> { From 8b91ecbc5686f10b8bead014f2deeb93d919ac10 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 18:48:48 +0100 Subject: [PATCH 075/192] token persistence example --- .../java/io/questdb/client/test/example/OIDCAuthExample.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java index e8ea79e01..dd5b69ff0 100644 --- a/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java +++ b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java @@ -1,6 +1,7 @@ package io.questdb.client.test.example; import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.FileTokenStore; import io.questdb.client.cutlass.auth.OidcDeviceAuth; import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; @@ -15,7 +16,9 @@ public static void main(String[] args) { // Discover the client id, scope and endpoints from the QuestDB server's /settings: try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( "http://localhost:9000", - new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true) + new OidcDeviceAuth.DiscoveryOptions() + .allowInsecureTransport(true) + .tokenStore(FileTokenStore.atDefaultLocation()) )) { // one-time interactive sign-in; caches token + refresh token auth.signIn(); From 6334bba3b2da01f7cb6908286136977940d17b66 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 19:08:19 +0100 Subject: [PATCH 076/192] Fix OIDC clock-skew collapse for stored tokens adopt() set tokenTtlMillis to the remaining span (expiresAtMillis - now), while storeTokens() sets it to the full issued lifetime. effectiveSkewMillis() caps the 30s clock-skew margin at tokenTtlMillis / 2 - a guard meant only for a genuinely short-issued (< 60s) token - so a TokenStore-loaded token in its final minute had that margin collapse toward zero, and getToken() served it on the flush path instead of refreshing. Under client/server clock drift the served token could then be rejected mid-request, and on the ILP HTTP sender that 401 is not auto-recovered. adopt() now takes the skew basis from the file's stored issued TTL (clamped to [0, maxLife]), which snapshot() already persists, so adopt() and storeTokens() give tokenTtlMillis one meaning. 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 than deriving it from the remaining span. Replace testAdoptDerivesTtlFromExpiry, which pinned the old remaining-span behaviour, with testAdoptTrustsStoredIssuedTtlNot- RemainingSpan, and add testAdoptedTokenNearExpiryStillRefreshesOn- FlushPath as a regression guard. Both fail on the reverted production line and pass with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 15 ++++--- .../auth/OidcDeviceAuthPersistenceTest.java | 45 ++++++++++++++----- 2 files changed, 44 insertions(+), 16 deletions(-) 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 index cba2d01e3..f4750f065 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1059,11 +1059,16 @@ private boolean adopt(PersistedToken token) { long maxTokenLifeMillis = MAX_EXPIRES_IN_SECONDS * 1000L; long now = System.currentTimeMillis(); expiresAtMillis = Math.max(0L, Math.min(token.getExpiresAtMillis(), now + maxTokenLifeMillis)); - // derive the trusted lifetime from the clamped absolute expiry, not the file's separately-stored ttl, so - // a tampered file cannot make the two disagree and throw off effectiveSkewMillis (which caps the skew at - // half the lifetime); for a legitimate file the two already agree, and the expiry clamp bounds this to - // [0, maxLife] - tokenTtlMillis = Math.max(0L, expiresAtMillis - now); + // 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 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 index bd29a5ee0..257586cb5 100644 --- 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 @@ -57,27 +57,26 @@ public class OidcDeviceAuthPersistenceTest { public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build(); @Test(timeout = 30_000) - public void testAdoptDerivesTtlFromExpiry() throws Exception { + 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 (tampered) entry whose stored ttl (5m) disagrees with its absolute expiry (now + 10m). - // adopt() must derive the trusted lifetime from the authoritative expiry, not the stored ttl, so - // the effectiveSkewMillis basis matches the real remaining lifetime + // 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 + 600_000, 300_000); + 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"); - long expiry = readPrivateLong(auth, "expiresAtMillis"); - Assert.assertTrue("ttl must be derived from the ~10m expiry, not the stored 5m: " + ttl, - ttl >= 9 * 60_000L); - Assert.assertTrue("ttl must not exceed the clamped 1h lifetime: " + ttl, ttl <= 60 * 60_000L); - Assert.assertTrue("ttl must match expiresAtMillis - now within tolerance", - Math.abs(ttl - (expiry - now)) < 5_000L); + 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()); @@ -85,6 +84,30 @@ public void testAdoptDerivesTtlFromExpiry() throws Exception { }); } + @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 testBuildRejectsFileTokenStoreWithTooSmallStaleWindow() throws Exception { assertMemoryLeak(() -> { From 4b385ee68a2bb1a7beb89b5ff2a0cf92e7cb2b78 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 1 Jul 2026 19:23:50 +0100 Subject: [PATCH 077/192] Harden OIDC device-flow input validation Address the minor hardening findings from the device-flow review. endpointPathHasEncodedSeparator() out-decoded the endpoint path with a byte-oriented percentDecodeOnce and scanned each level for %2f/%5c/%25, but missed encodings it does not resolve - an overlong-UTF-8 %c0%ae or an IIS-style %u002e that a permissive server decodes to '.'/'/'. Such a segment, sitting past the issuer prefix, slipped the path scope. A real OIDC endpoint path is plain ASCII, so reject any '%' or '\' in it outright; a provider that encodes its path must be configured explicitly with builder(), which pins the origin only. adopt() accepted an empty served token (hasOnlyTokenChars("") is vacuously true) and would serve it as a blank "Bearer " header that only draws a 401. Reject an empty served token too, so a corrupt or tampered entry falls through to a refresh or an interactive sign-in. Endpoint.parse() let Integer.parseInt read a ":+443" port as 443, slipping the range check, and accepted a backslash in the host that the WHATWG URL spec folds to '/'. Reject a leading '+' on the port and a backslash in the host. Correct the JsonLexer.unescape() default-case comment: a '\' before a recognized escape letter is still decoded, so the "Windows path is not dropped" note held only for genuinely unknown escapes. Add regression tests for each - overlong-UTF-8 / %u endpoint paths, an empty served token, and the +port / backslash-host cases; every one fails on the reverted production line and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 65 ++++++++++--------- .../client/cutlass/json/JsonLexer.java | 7 +- .../auth/OidcDeviceAuthPersistenceTest.java | 27 ++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 12 ++++ 4 files changed, 79 insertions(+), 32 deletions(-) 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 index f4750f065..f161bacf9 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -653,32 +653,22 @@ private static OidcAuthException endpointOriginNotPinned(String label, String ur } private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) { - // Scan for a literal backslash (decodePathSegments folds it to '/') or a percent-encoded path - // separator - %2f ('/'), %5c ('\'), or an encoded percent %25 that gates a split or double encoding - // such as %2%66 or %252f - at every decode level, not just the raw string. A separator that only - // emerges after the server unescapes more than once would pass a single-pass scan yet split one - // segment in two, letting .../realms/acme%2%66evil/token slip the issuer-path scope. A real OIDC - // endpoint path encodes none of these. Bounded like decodePathSegments; a real path needs 0-1 passes. - String decoded = rawEndpointPath; - for (int pass = 0; pass < 10; pass++) { - for (int i = 0, n = decoded.length(); i < n; i++) { - char c = decoded.charAt(i); - if (c == '\\') { - return true; - } - if (c == '%' && i + 2 < n) { - char a = decoded.charAt(i + 1); - char b = decoded.charAt(i + 2); - if ((a == '2' && (b == 'f' || b == 'F' || b == '5')) || (a == '5' && (b == 'c' || b == 'C'))) { - return true; - } - } - } - String next = percentDecodeOnce(decoded); - if (next.equals(decoded)) { - break; + // 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; } - decoded = next; } return false; } @@ -1032,9 +1022,11 @@ private boolean adopt(PersistedToken token) { } // the file is attacker-writable, so treat the served token (the one getToken() puts verbatim into an // Authorization header or a PG-wire password) as untrusted: reject a control/non-ASCII char - and the - // whole entry - rather than route a tampered credential onto the wire. A null served token is unusable. + // whole entry - rather than route a tampered credential onto the wire. A null OR empty served token is + // unusable: an empty string passes hasOnlyTokenChars vacuously but would be served as a blank + // "Bearer " header that only draws a 401, so reject it here and fall through to a refresh or sign-in. String servedToken = groupsInToken ? token.getIdToken() : token.getAccessToken(); - if (servedToken == null || !hasOnlyTokenChars(servedToken)) { + if (servedToken == null || servedToken.isEmpty() || !hasOnlyTokenChars(servedToken)) { return false; } accessToken = token.getAccessToken(); @@ -2016,8 +2008,16 @@ static Endpoint parse(String url) { 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(hostPort.substring(colon + 1)); + port = Integer.parseInt(portStr); } catch (NumberFormatException e) { throw new OidcAuthException().put("invalid url, could not parse the port [url=").put(url).put(']'); } @@ -2038,9 +2038,16 @@ static Endpoint parse(String url) { // 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++) { - if (host.charAt(i) > 0x7f) { + 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); } 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 0fe38eca3..6eac116f8 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 @@ -416,9 +416,10 @@ private CharSequence unescape(CharSequence raw) { } break; default: - // unknown escape: keep the backslash and the escaped character verbatim (lenient), so a - // literal backslash in non-conformant input (e.g. a Windows path in an error body) is not - // dropped + // 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; 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 index 257586cb5..9ccb06305 100644 --- 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 @@ -595,6 +595,33 @@ public void testStoreLoadedAtMostOncePerInstance() throws Exception { }); } + @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(() -> { 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 index 43ac4bf10..dc31bc1e2 100644 --- 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 @@ -1075,6 +1075,9 @@ public void testEndpointParseRejectsMalformedUrls() { 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"); @@ -1105,6 +1108,9 @@ public void testEndpointParseRejectsMalformedUrls() { // 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) @@ -1934,6 +1940,12 @@ public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() thr // rejects it at the encoded level too, before decodePathSegments would fold it to '/' Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5cevil/token", issuer)); Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5Cevil/token", issuer)); + // overlong-UTF-8 (%c0%ae, %e0%80%ae) and an IIS-style %u002e encode a '.'/'/' that a permissive server + // resolves but a byte-oriented percent decode leaves as high bytes or literal text; sitting past the + // issuer prefix they would slip the '..'/segment scan, so any '%' in an endpoint path is rejected + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%c0%ae%c0%ae/evil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%e0%80%ae%e0%80%ae/evil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%u002e%u002e/evil/token", issuer)); } @Test(timeout = 30_000) From c726c13b8e5274228e5a11fbece198602e0dd911 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 14 Jul 2026 17:17:21 +0100 Subject: [PATCH 078/192] Fix three OIDC device-flow token-provider defects Three confirmed defects in the OIDC device-flow token provider, each with a regression test proven to fail without the fix: - Blank served token: adopt() and storeTokens() accepted a whitespace-only served token (isEmpty/hasOnlyTokenChars pass it vacuously), so signIn() reported success and getToken() served a "Bearer " header the server only answers with 401 - never falling back to a refresh or sign-in. adopt() now rejects it via Chars.isBlank; storeTokens() folds a blank served kind to absent so selectToken() surfaces the actionable "no access_token". - getToken() lock contention: the unconditional tryLock() failed fast on ANY lock hold, so concurrent callers sharing one OidcDeviceAuth (the documented shared-provider pattern) threw on every token refresh. getToken() now waits briefly behind a peer's quick silent refresh - bounded by httpTimeoutMillis - and fails fast only behind an interactive sign-in, tracked by a new flag. - SYNC initial connect: connectWithRetry() treated a token-provider failure as a transport outage and retried it for the whole reconnect budget (5 min default), then wrapped it. It now fails fast with the provider's own exception, matching the OFF-mode and background-reconnect paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 91 +++++++++++++++---- .../sf/cursor/CursorWebSocketSendLoop.java | 13 +++ .../auth/OidcDeviceAuthPersistenceTest.java | 28 ++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 79 ++++++++++++---- .../client/WebSocketTokenProviderTest.java | 43 +++++++++ 5 files changed, 217 insertions(+), 37 deletions(-) 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 index f161bacf9..0b122bd83 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -49,6 +49,7 @@ import java.net.URLEncoder; import java.util.Locale; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; /** @@ -120,6 +121,10 @@ public class OidcDeviceAuth implements QuietCloseable { private static final int DEFAULT_TOKEN_TTL_SECONDS = 300; 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); @@ -189,6 +194,10 @@ public class OidcDeviceAuth implements QuietCloseable { 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; private HttpClient plainClient; @@ -469,7 +478,11 @@ public String getAuthorizationHeaderValue() { *

    * 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 is not, however, instantaneous - when the cached + * 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 by + * {@link Builder#httpTimeoutMillis(int)} and still failing 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 briefly to acquire the store's per-identity lock - a few * seconds at most for {@link FileTokenStore}, then it proceeds without the lock - before that round-trip). @@ -483,18 +496,12 @@ public String getAuthorizationHeaderValue() { * * @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, or if a sign-in or - * refresh is already in progress on another thread + * 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(); - // never wait on the flush path: signIn()'s sign-in holds the lock for the whole device-code - // lifetime (up to 30 minutes), so tryLock and fail fast if held. A sign-in in progress means there - // is no token to serve yet, so the caller gets a prompt exception to retry rather than a stalled - // flush - if (!lock.tryLock()) { - throw new OidcAuthException("a sign-in or token refresh is already in progress on another thread; no token is available without blocking - retry shortly"); - } + acquireForGetToken(); try { throwIfClosed(); maybeLoadFromStore(); @@ -541,7 +548,14 @@ public String signIn() { return selectToken(); } } - runDeviceFlow(); + // 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(); @@ -1000,7 +1014,9 @@ private static void validateTokenChars(CharSequence token, String tokenName) { // 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. + // 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) @@ -1016,17 +1032,50 @@ private static String wellKnownUrl(String issuer) { return trimmed + WELL_KNOWN_OPENID_CONFIGURATION_PATH; } + private void acquireForGetToken() { + // 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 by httpTimeoutMillis so a stuck or pathologically slow holder degrades to + // a retryable failure instead of stalling the flush path without bound. + final long deadline = System.currentTimeMillis() + httpTimeoutMillis; + 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 remaining = deadline - System.currentTimeMillis(); + if (remaining <= 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(remaining, GET_TOKEN_LOCK_POLL_SLICE_MILLIS), TimeUnit.MILLISECONDS)) { + 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 treat the served token (the one getToken() puts verbatim into an // Authorization header or a PG-wire password) as untrusted: reject a control/non-ASCII char - and the - // whole entry - rather than route a tampered credential onto the wire. A null OR empty served token is - // unusable: an empty string passes hasOnlyTokenChars vacuously but would be served as a blank - // "Bearer " header that only draws a 401, so reject it here and fall through to a refresh or sign-in. + // whole entry - rather than route a tampered credential onto the wire. A null, empty OR blank + // (whitespace-only) served token is unusable: it passes hasOnlyTokenChars vacuously (space is 0x20) + // but would be served as a blank "Bearer " header that only draws a 401 - and the sender's own + // HttpTokenProvider.validateToken (Chars.isBlank) rejects it downstream anyway - so reject it here on + // the same isBlank contract and fall through to a refresh or sign-in rather than wedge on it. String servedToken = groupsInToken ? token.getIdToken() : token.getAccessToken(); - if (servedToken == null || servedToken.isEmpty() || !hasOnlyTokenChars(servedToken)) { + if (Chars.isBlank(servedToken) || !hasOnlyTokenChars(servedToken)) { return false; } accessToken = token.getAccessToken(); @@ -1456,8 +1505,14 @@ private void storeTokens(TokenResponseParser parser) { } else { validateTokenChars(parser.accessToken, "access_token"); } - accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null; - idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null; + // 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(); // a refresh response usually omits a new refresh token; keep the current one in that case if (parser.refreshToken.length() > 0) { refreshToken = parser.refreshToken.toString(); 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 df2945c4f..1c054b25b 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 @@ -595,6 +595,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 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 index 9ccb06305..1ed9c7033 100644 --- 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 @@ -595,6 +595,34 @@ public void testStoreLoadedAtMostOncePerInstance() throws Exception { }); } + @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(() -> { 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 index dc31bc1e2..2066c7ec7 100644 --- 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 @@ -1509,13 +1509,15 @@ public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception { } @Test(timeout = 30_000) - public void testGetTokenDoesNotBlockBehindSilentRefresh() throws Exception { - assertMemoryLeak(() -> { - // the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not - // just an interactive sign-in: getToken() must fail fast rather than queue behind it. The - // cached token is forced expired so getToken() refreshes; the token endpoint blocks the - // refresh response until the test releases it, pinning the lock on the refresher thread while the - // second caller races for it + 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); MockOidcServer.Handler handler = (method, path, body) -> { @@ -1531,7 +1533,7 @@ public void testGetTokenDoesNotBlockBehindSilentRefresh() throws Exception { } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 1)); + 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 }; @@ -1554,23 +1556,37 @@ public void testGetTokenDoesNotBlockBehindSilentRefresh() throws Exception { }, "oidc-silent-refresh"); refresher.setDaemon(true); refresher.start(); - try { - Assert.assertTrue("the silent refresh did not start", refreshInFlight.await(10, TimeUnit.SECONDS)); - // a refresh holds the lock now; getToken() on this thread must fail fast, not block - long startNanos = System.nanoTime(); + 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 { - auth.getToken(); - Assert.fail("expected getToken() to fail fast while a refresh is in progress"); - } catch (OidcAuthException e) { - long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; - Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight refresh", - elapsedMillis < 2_000); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); + waiterResult.set(auth.getToken()); + } catch (Throwable t) { + waiterError.set(t); } + }, "oidc-getToken-waiter"); + waiter.setDaemon(true); + waiter.start(); + 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 - no result and, crucially, no error yet + Thread.sleep(500); + 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("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()); } }); } @@ -1625,6 +1641,31 @@ public void testGetTokenRefreshesWithoutPrompting() throws Exception { }); } + @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())) { + try { + auth.signIn(); + Assert.fail("expected signIn() to reject a blank served token from the wire"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no access_token")); + } + } + }); + } + @Test(timeout = 30_000) public void testGroupsInTokenButNoIdTokenFails() throws Exception { assertMemoryLeak(() -> { 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 index 399b20424..02df6e66a 100644 --- 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 @@ -177,6 +177,49 @@ public void testThrowingProviderResolvedOncePerConnectRound() throws Exception { }); } + @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(() -> { From 141855470c5b4968d61c33f5c28d1e057c64aa2c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 14 Jul 2026 17:36:59 +0100 Subject: [PATCH 079/192] Cover untested OIDC load-bearing guards Add regression tests for four load-bearing guards that had no coverage; each is proven to fail without the guard: - JsonLexer.parseHex4 non-ASCII window guard: a backslash-u escape whose four-hex window holds a non-ASCII char (valid UTF-8) is kept verbatim; without the c<128 guard it throws an AIOOBE on the int[128] hex table, which escapes as an unchecked exception past the OIDC callers (they catch only JsonException). - isEndpointUnderIssuerPath raw dot-segment reject: a bare (unencoded) ".." or "." segment - which every other traversal test encodes and an earlier gate catches - must be rejected so a tampered /settings cannot steer credentials to a sibling realm. - FileTokenStore size caps: the two existing tests passed regardless of the cap (an all-spaces file failed the version check anyway; a stale lock stole on mtime regardless of size). Reworked to isolate each cap - a valid oversized token file, and a lock in the window where only an unreadable (capped) read is stolen. - CursorWebSocketSendLoop credential-timer reset: a credential blip interleaved with transient role rejects must not accumulate toward the terminal budget; without the reset the sender terminates and buffered store-and-forward data is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/cutlass/auth/FileTokenStoreTest.java | 40 +++++++++--- .../test/cutlass/auth/OidcDeviceAuthTest.java | 17 +++++ .../test/cutlass/json/JsonLexerTest.java | 38 +++++++++++ .../client/WebSocketTokenProviderTest.java | 65 +++++++++++++++++++ 4 files changed, 150 insertions(+), 10 deletions(-) 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 index aa1bebdc5..759588245 100644 --- 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 @@ -736,11 +736,24 @@ public void testOversizedFileReturnsNull() throws Exception { Path dir = storeDir(); FileTokenStore store = new FileTokenStore(dir); TokenStoreKey key = sampleKey(); - Files.createDirectories(dir); - byte[] big = new byte[(1 << 20) + 1]; - java.util.Arrays.fill(big, (byte) ' '); - Files.write(tokenFile(dir, key), big); - Assert.assertNull(store.load(key)); + // 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)); }); } @@ -749,13 +762,20 @@ public void testOversizedStaleLockIsStolen() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); Files.createDirectories(dir); - FileTokenStore store = new FileTokenStore(dir, 2000, 100); + // 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, backdated past the staleness window. 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. + // 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); 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 index 2066c7ec7..5f3609476 100644 --- 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 @@ -1964,6 +1964,23 @@ public void testIssuerPathScopingRejectsSiblingRealm() throws Exception { }); } + @Test(timeout = 30_000) + public void testIssuerPathScopingRejectsRawDotSegments() throws Exception { + // a RAW (unencoded) ".." or "." path segment carries no '%' or '\' (so endpointPathHasEncodedSeparator + // passes it) and no '?'/'#'/control (so Endpoint.parse accepts it), yet a lenient server normalizes + // .../realms/acme/../evil/token to a different realm - so the segment scan in isEndpointUnderIssuerPath + // must reject a bare '.'/'..' segment. Every OTHER traversal test feeds a percent-encoded or '#'/'?' + // form caught by an earlier gate; only these bare-dot cases exercise that dot-segment loop. + String issuer = "https://idp.example.com/realms/acme"; + // control: a genuine sub-path endpoint stays accepted (proves the check is not rejecting everything) + Assert.assertTrue(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/token", issuer)); + // a parent-traversal segment escapes the issuer path once the server normalizes it -> rejected + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/../evil/token", issuer)); + // a single-dot segment and a mix are likewise normalized away and must not slip the scan + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/./../evil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/./acme/token", issuer)); + } + @Test(timeout = 30_000) public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() throws Exception { // hardening: an encoded path separator can hide behind a SPLIT encoding (%2%66 -> %2f -> '/') or a 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 ee9505cf1..dc410272c 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 @@ -741,6 +741,44 @@ public void testUnicodeEscapeDecodedAcrossSplitParseCalls() throws Exception { }); } + @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(() -> { 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 index 02df6e66a..b947f87d5 100644 --- 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 @@ -321,6 +321,71 @@ public void testPersistentlyThrowingProviderOnReconnectTerminatesTheSender() thr }); } + @Test(timeout = 30_000) + public void testCredentialFailureTimerResetsAcrossTransientReconnectFailures() throws Exception { + assertMemoryLeak(() -> { + // The SF credential terminal budget must accumulate only across an UNINTERRUPTED run of + // credential-acquisition failures: a transient reconnect failure (here a 421 role reject) between + // credential blips must RESET the timer, so a provider that fails only intermittently - interleaved + // with transport/role failures - never terminates the sender even when the total credential-failing + // span far exceeds the reconnect budget. Without the reset (credentialFailingSinceNanos = 0L on the + // transport / role-mismatch catch) the credential blips accumulate and terminate the sender at the + // budget, so batch 2 would never land. + 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 = 800; // short: WITHOUT the reset, accumulation terminates well before recovery + 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 -> timer starts), odd calls RETURN a + // valid token (whose connect then hits the transient 421 role reject below -> the + // role-mismatch catch RESETS the timer). So credential blips and role rejects strictly + // alternate: the timer is set then reset each cycle and never approaches the budget. + 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 the + // reconnect attempts deterministically hit it (no race where a reconnect succeeds first). The + // already-established initial connection is unaffected and still ships batch 1. + server.setRejectWithRole("replica"); + // batch 1 lands on the established connection and is ACKed, then the server drops the socket + // -> the background reconnect loop starts and enters the credential-blip / role-reject cycle + 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; if the reset + // were broken the credential blips would accumulate and terminate the sender by now + Thread.sleep(budgetMillis * 3); + + // clear the reject: the next token-returning reconnect attempt now succeeds. The sender must + // still be alive (never terminated during the reject phase), so batch 2 lands. + server.setRejectWithRole(null); + sender.table("foo").longColumn("v", 2L).atNow(); + 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(() -> { From 304663f8e9a21f5c71f4182f1e882cd3cf88197d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 14 Jul 2026 18:39:45 +0100 Subject: [PATCH 080/192] Address moderate OIDC review findings - Discovery parsers now reject array-wrapped JSON: an object nested in an array (e.g. {"config":[{...}]}) can no longer surface its fields at the trusted config / top-level depth. All four discovery parsers track array depth and read names/values only at array depth 0, mirroring FileTokenStore's parser. Adds a regression test. - close() frees the native JsonLexer before the HttpClients, so a throw from a client close cannot strand the native buffer. - BrowserLauncher: extract the kill-switch into a testable isBrowserOpenEnabled() predicate; the test now asserts the gate actually flips (it previously had no assertion on the kill-switch). - SenderBuilderErrorApiTest: replace vacuous assertNotNull on enum constants with valueOf() checks that fail at runtime if a constant is renamed or removed. - Docs: HttpTokenProvider.getToken() states the silent-refresh connect stall (OS-bounded, ~2 min worst case); TokenStore.inLock documents the no-reentrancy / no-blocking contract; FileTokenStore honestly states the concurrent-refresh residual (token-family revocation on a reuse-detecting IdP, headless hard-failure) rather than "just a re-prompt". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 9 ++- .../client/cutlass/auth/BrowserLauncher.java | 11 +++- .../client/cutlass/auth/FileTokenStore.java | 13 +++- .../client/cutlass/auth/OidcDeviceAuth.java | 65 +++++++++++++++---- .../client/cutlass/auth/TokenStore.java | 6 ++ .../test/SenderBuilderErrorApiTest.java | 11 ++-- .../cutlass/auth/BrowserLauncherTest.java | 21 ++++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 33 ++++++++++ 8 files changed, 143 insertions(+), 26 deletions(-) diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index e8e306577..2344b8a92 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -38,8 +38,13 @@ * 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. An - * exception from {@link #getToken()} fails the in-flight flush (HTTP) or the connection attempt (WebSocket). + * 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 OS, + * not by the client timeout - so a black-holed token endpoint can stall a refresh for the OS connect + * timeout (commonly ~2 minutes on Linux). A producer sizing flush backpressure against this call should + * expect that worst case. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the + * connection attempt (WebSocket). * * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider) */ 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 index da2d72cc1..d31050929 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java @@ -42,6 +42,15 @@ final class BrowserLauncher { 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 @@ -50,7 +59,7 @@ private BrowserLauncher() { * treats that as "no browser available". */ static void open(String url) { - if (!Boolean.parseBoolean(System.getProperty(OPEN_BROWSER_PROPERTY, "true"))) { + if (!isBrowserOpenEnabled()) { return; } URI uri = safeHttpUri(url); 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 index 4e058284a..2abcbc174 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -80,6 +80,15 @@ * 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. */ @@ -98,7 +107,9 @@ public final class FileTokenStore implements TokenStore { // 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, degrading (best-effort) to a re-prompt on a rotating-refresh-token IdP + // 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------")); 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 index 0b122bd83..50680af53 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -453,9 +453,12 @@ public void close() { closed = true; lock.lock(); try { + // 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); - jsonLexer = Misc.free(jsonLexer); } finally { lock.unlock(); } @@ -1878,6 +1881,9 @@ private static final class DeviceAuthorizationResponseParser implements JsonPars 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; @@ -1891,6 +1897,7 @@ public void clear() { verificationUriComplete.clear(); expiresIn = 0; interval = 0; + arrayDepth = 0; depth = 0; field = FIELD_NONE; } @@ -1898,6 +1905,12 @@ public void clear() { @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; @@ -1905,7 +1918,7 @@ public void onEvent(int code, CharSequence tag, int position) { depth--; break; case JsonLexer.EVT_NAME: - if (depth == 1) { + if (arrayDepth == 0 && depth == 1) { if (Chars.equals("device_code", tag)) { field = FIELD_DEVICE_CODE; } else if (Chars.equals("user_code", tag)) { @@ -1928,7 +1941,7 @@ public void onEvent(int code, CharSequence tag, int position) { } break; case JsonLexer.EVT_VALUE: - if (depth == 1) { + if (arrayDepth == 0 && depth == 1) { switch (field) { case FIELD_DEVICE_CODE: putNonNull(deviceCode, tag); @@ -2124,6 +2137,11 @@ private static final class SettingsDiscoveryParser implements JsonParser { 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; @@ -2132,9 +2150,15 @@ private static final class SettingsDiscoveryParser implements JsonParser { @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 (depth == 2 && isConfigNext) { + if (arrayDepth == 0 && depth == 2 && isConfigNext) { isInConfig = true; } isConfigNext = false; @@ -2146,12 +2170,12 @@ public void onEvent(int code, CharSequence tag, int position) { depth--; break; case JsonLexer.EVT_NAME: - if (depth == 1) { + 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 (depth == 2 && isInConfig) { + } 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)) { @@ -2174,7 +2198,7 @@ public void onEvent(int code, CharSequence tag, int position) { } break; case JsonLexer.EVT_VALUE: - if (depth == 2 && isInConfig) { + if (arrayDepth == 0 && depth == 2 && isInConfig) { switch (field) { case FIELD_ENABLED: isOidcEnabled = Chars.equals("true", tag); @@ -2223,6 +2247,9 @@ private static final class TokenResponseParser implements JsonParser, Mutable { 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; @@ -2234,6 +2261,7 @@ public void clear() { idToken.clear(); refreshToken.clear(); expiresIn = 0; + arrayDepth = 0; depth = 0; field = FIELD_NONE; } @@ -2241,6 +2269,12 @@ public void clear() { @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; @@ -2248,7 +2282,7 @@ public void onEvent(int code, CharSequence tag, int position) { depth--; break; case JsonLexer.EVT_NAME: - if (depth == 1) { + if (arrayDepth == 0 && depth == 1) { if (Chars.equals("access_token", tag)) { field = FIELD_ACCESS_TOKEN; } else if (Chars.equals("id_token", tag)) { @@ -2267,7 +2301,7 @@ public void onEvent(int code, CharSequence tag, int position) { } break; case JsonLexer.EVT_VALUE: - if (depth == 1) { + if (arrayDepth == 0 && depth == 1) { switch (field) { case FIELD_ACCESS_TOKEN: putNonNull(accessToken, tag); @@ -2305,12 +2339,21 @@ private static final class WellKnownDiscoveryParser implements JsonParser { 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; @@ -2320,7 +2363,7 @@ public void onEvent(int code, CharSequence tag, int position) { 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 (depth == 1) { + if (arrayDepth == 0 && depth == 1) { if (Chars.equals("device_authorization_endpoint", tag)) { field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT; } else if (Chars.equals("token_endpoint", tag)) { @@ -2331,7 +2374,7 @@ public void onEvent(int code, CharSequence tag, int position) { } break; case JsonLexer.EVT_VALUE: - if (depth == 1) { + if (arrayDepth == 0 && depth == 1) { switch (field) { case FIELD_DEVICE_AUTHORIZATION_ENDPOINT: putNonNull(deviceAuthorizationEndpoint, tag); 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 index 42394fc24..e0c62b8e8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -64,6 +64,12 @@ public interface TokenStore { * 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. * * @param key the identity to lock * @param action the critical section; its boolean result is returned unchanged 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 3441905aa..66708ba38 100644 --- a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java +++ b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java @@ -230,12 +230,11 @@ 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. valueOf(name) throws at + // RUNTIME if a constant is renamed or removed, so this actually fails on a regression - unlike + // asserting a compiled constant reference is non-null, which the compiler already guarantees. + Assert.assertEquals(SenderError.Category.SCHEMA_MISMATCH, SenderError.Category.valueOf("SCHEMA_MISMATCH")); + Assert.assertEquals(SenderError.Policy.RETRIABLE, SenderError.Policy.valueOf("RETRIABLE")); } @Test 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 index 21d80da96..dfcfa6494 100644 --- 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 @@ -55,16 +55,21 @@ public void testOpenIsBestEffortForRejectedUrls() throws Exception { @Test public void testOpenRespectsDisableProperty() throws Exception { - // a VALID http(s) URL: if open() did not short-circuit on the kill-switch it would proceed toward - // java.awt.Desktop, so asserting safeHttpUri accepts it proves the no-op below is the kill-switch, - // not URL rejection. This gate is also what keeps the suite from launching a real browser on a - // developer machine. + // the kill-switch itself is asserted via isBrowserOpenEnabled() - a real browser launch is + // unobservable (no-op on a headless JVM either way), so asserting the property read directly is the + // only way to prove the gate actually flips. A VALID http(s) URL confirms the no-op under "false" + // below is the kill-switch, not URL rejection; this gate also keeps the suite from popping 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); - System.setProperty(prop, "false"); 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) { @@ -90,6 +95,12 @@ public void testRejectsDangerousOrMalformedUrls() throws Exception { // 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); 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 index 5f3609476..fd3bcf378 100644 --- 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 @@ -835,6 +835,39 @@ public void testDiscoveryDefaultsScopeToOpenid() throws Exception { }); } + @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); + try { + OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()); + Assert.fail("array-wrapped config must not be trusted as OIDC config"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("OIDC is not enabled")); + } + } + }); + } + @Test(timeout = 30_000) public void testDiscoveryIgnoresPreferencesKeys() throws Exception { assertMemoryLeak(() -> { From 07f809bdd8863dca04204466c325bd07673d8d59 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 14 Jul 2026 19:55:07 +0100 Subject: [PATCH 081/192] Build ILP token-provider request once per flush Two moderate perf findings on the ILP-over-HTTP token-provider flush path: - Build the request once, not twice. The deferred-token design (getToken() is pulled at the first row, not on the flush- completion path, for throw-safety and build-before-signIn) previously built the request line and headers in reset(), then discarded and rebuilt them when the first row stamped the token. Now newRequest() leaves the request at the header stage (no withContent yet) and stampTokenIfPending() appends the auth header + withContent() on the same request - no second client.newRequest(). Only the token-provider path changes; bufferView() still reads empty before the first row, and the throwing pull runs before the request is mutated so a failed getToken() leaves it retriable, not corrupted. - Skip re-validating an unchanged token. validateToken() scans the whole (multi-KB) token; it is now skipped when the provider returns the same instance already validated, and always run for a null or changed token. Adds a regression test that a token changed to a bad one is re-validated and rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../line/http/AbstractLineHttpSender.java | 84 +++++++++++-------- .../line/LineHttpSenderTokenProviderTest.java | 34 ++++++++ 2 files changed, 84 insertions(+), 34 deletions(-) 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 a0202cdaa..06db4b4a8 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 @@ -94,6 +94,9 @@ public abstract class AbstractLineHttpSender implements Sender { private boolean isTokenPending; private JsonErrorParser jsonErrorParser; private boolean lastFlushFailed; + // the last provider token instance validated in stampTokenIfPending(); lets an unchanged multi-KB token + // skip re-validation (a full scan) on every flush. Identity only, never dereferenced for content. + private CharSequence lastValidatedToken; private long pendingRows; private int rowBookmark; private RequestState state = RequestState.EMPTY; @@ -204,8 +207,8 @@ protected AbstractLineHttpSender( : HttpClientFactory.newPlainTextInstance(clientConfiguration); } this.questDBVersion = new BuildInformationHolder().getSwVersion(); - // precompute the User-Agent header value once: newRequest() runs on every flush (and twice per flush - // for a token provider), so concatenating it there would allocate a String each time + // 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; @@ -414,12 +417,15 @@ public static AbstractLineHttpSender createLineSender( throw new LineSenderException("Unsupported protocol version: " + protocolVersion); } if (httpTokenProvider != null) { - // The constructor already built the initial request without a token. Defer the first - // getToken() off this build path to the first row (table()), so a provider that signs in - // lazily - e.g. OidcDeviceAuth::getToken - can be wired before sign-in completes, - // and the token pull stays on the use/flush path the provider documents. + // 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.isTokenPending = true; + sender.request = sender.newRequest(); } return sender; } @@ -757,10 +763,6 @@ private void flush0(boolean closing) { } private HttpClient.Request newRequest() { - return newRequest(false); - } - - private HttpClient.Request newRequest(boolean pullProviderToken) { HttpClient.Request r = client.newRequest(currentHost(), currentPort()) .POST() .url(path) @@ -768,22 +770,17 @@ private HttpClient.Request newRequest(boolean pullProviderToken) { if (username != null) { r.authBasic(username, password); } else if (httpTokenProvider != null) { - if (pullProviderToken) { - // pull a fresh token per request so a long-lived sender 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 splice a - // malformed or CR/LF-injected "Authorization: Bearer " header onto the wire - CharSequence token = httpTokenProvider.getToken(); - HttpTokenProvider.validateToken(token); - r.authToken(token); - } else { - // do NOT pull the token on the construct/flush path: getToken() can throw (not signed in - // yet, or a failed silent refresh). Here - after client.newRequest() reset and re-headered - // the shared request but before withContent() - a throw would leave a half-built request - // and corrupt the sender, turning an already-successful flush into an exception. Defer to - // the first row (stampTokenIfPending), where a failed pull is retriable and rebuilds cleanly. - isTokenPending = true; - } + // 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); } @@ -820,13 +817,32 @@ private boolean rowAdded() { 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 - // rebuild the still-empty request to carry the token before any row data goes in. Clear the - // flag only after newRequest(true) succeeds: a pull that throws (not signed in yet, or a failed - // refresh) leaves the stamp pending, so the next row re-runs this and fully rebuilds the - // request - the sender is never left corrupted. - request = newRequest(true); + // 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. validateToken (which scans the whole token) is skipped when the provider returned the + // same instance already validated: an unchanged multi-KB id token is otherwise re-scanned every + // flush. A provider returning the same instance must not mutate its content (HttpTokenProvider + // contract), so the skip cannot let a changed token bypass validation. + CharSequence token = httpTokenProvider.getToken(); + // always validate a null token (it must be rejected, and null is the initial lastValidatedToken + // value); otherwise skip re-validation only for the exact same instance already validated. + // lastValidatedToken is assigned only after a successful validation, so it never holds a rejected + // (null/blank/bad-char) token - a re-returned bad token is therefore re-validated and re-rejected. + if (token == null || token != lastValidatedToken) { + HttpTokenProvider.validateToken(token); + lastValidatedToken = token; + } + request.authToken(token); + request.withContent(); + rowBookmark = request.getContentLength(); + state = RequestState.EMPTY; isTokenPending = false; } } 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 index c37ee9b76..2d08f8ef4 100644 --- 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 @@ -87,6 +87,40 @@ public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception { }); } + @Test(timeout = 30_000) + public void testChangedProviderTokenIsRevalidated() throws Exception { + assertMemoryLeak(() -> { + // the per-flush token validation is skipped only for the SAME instance already validated, so a + // token that CHANGES to a bad one must still be re-validated and rejected - the identity guard must + // not cache a previously-valid result past a token change. First flush a valid token, then return a + // CR/LF token and require the next flush to reject it rather than splice it onto the wire. + 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(() -> { From 82ae4af5b6e15c0a8f758f185394c71dce83228f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 14 Jul 2026 20:29:35 +0100 Subject: [PATCH 082/192] Address remaining moderate OIDC review findings The two moderate findings previously left as documented residuals, now fixed since the design doc (new in this PR) can be updated in tandem with the Python client: - Eliminate the lock-file create->stamp gap. acquireLock created an empty .lock then stamped it in a second call, so a GC/safepoint pause straddling the two could make a freshly-created lock look empty-and-stale to a peer and be false-stolen, risking a concurrent refresh. createLockFile now creates the lock AND writes the owner nonce in one atomic exclusive open (CREATE_NEW), so a live lock always carries a stamp - there is no Java-level gap. The empty-lock grace remains for the rare crash-mid-write. writeLockHolder and its ownership-verification dance are gone. The design doc is updated to the atomic create-with-stamp protocol the Python client must mirror. - Cover the ILP flush response whole-read timeout bound end to end. The no-arg recv() the flush uses bounds the WHOLE body read to the configured timeout, not each socket read; that was unit-tested on the Response classes but not driven from a real flush over a real socket. A new MockOidcServer.dribble() sends chunked headers then the chunk-size line one byte at a time (never completing), and the test asserts a flush aborts on the ~1s request timeout - proven to fail (~11s) with the whole-read bound removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 99 ++++++++----------- .../test/cutlass/auth/MockOidcServer.java | 30 ++++++ .../line/LineHttpSenderErrorResponseTest.java | 36 +++++++ design/oidc-token-persistence.md | 45 +++++---- 4 files changed, 128 insertions(+), 82 deletions(-) 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 index 2abcbc174..7e1b377c0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -54,6 +54,7 @@ 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.atomic.AtomicBoolean; @@ -324,12 +325,18 @@ long getLockStaleMillis() { return lockStaleMillis; } - private static void createLockFile(Path lock) throws IOException { + private static void createLockFile(Path lock, String nonce) throws IOException { + // Exclusively create the lock (O_CREAT|O_EXCL via CREATE_NEW) AND write the owner nonce in a single + // open, so there is NO create->stamp gap for a GC/safepoint pause (or a cross-machine clock skew) to + // straddle and make our freshly-created lock look empty-and-stale to a peer. 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 { - Files.createFile(lock, FILE_ATTRS); + writeNewFile(lock, bytes, FILE_ATTRS); } catch (UnsupportedOperationException e) { warnNoPosixPermsOnce(); - Files.createFile(lock); + writeNewFile(lock, bytes); } } @@ -649,30 +656,14 @@ private static void writeAndFlush(Path file, byte[] content) throws IOException } } - private static boolean writeLockHolder(Path lock, String nonce) { - // stamp the lock with the owner nonce. Unlike the staleness mtime (which only needs to be recent), - // this content is what releaseLock checks before deleting, so it must be written reliably; report a - // failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release. - // Writing also refreshes the mtime, which is what the staleness age check reads - try { - // acquireLock created this lock empty; if it already carries a stamp, a peer judged it stale and - // stole+restamped it in the create->stamp gap (a long GC/suspend pause between the two syscalls, or - // a cross-machine clock skew wider than the empty-lock grace). A plain WRITE|TRUNCATE_EXISTING has no - // exclusivity and would overwrite that stamp, leaving two processes each believing they hold the - // lock, so refuse when a stamp is already present - honouring releaseLock's own-stamp ownership rule. - // This read-then-write is two syscalls, so like releaseLock's own read-then-delete it only NARROWS - // the clobber window (a steal landing between the read and the write is still overwritten); it does - // not close it. There is no atomic "write-only-if-still-empty" primitive to close it with. The - // residual degrades to at most the documented double-refresh (a re-prompt on a rotating IdP), never - // a torn or forged credential - Layer-1's atomic rename holds regardless. A readLockHolder that - // throws (our file was moved away during the peer's steal) is caught below and likewise fails the stamp. - if (readLockHolder(lock) != null) { - return false; + 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); } - Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); - return true; - } catch (Exception e) { - return false; } } @@ -684,27 +675,10 @@ private String acquireLock(Path lock) { final long deadline = System.currentTimeMillis() + lockAcquireBudgetMillis; while (true) { try { - createLockFile(lock); - if (writeLockHolder(lock, nonce)) { - return nonce; - } - // the exclusive create won the lock but the owner nonce could not be stamped, so releaseLock - // could not later prove ownership and would risk deleting a peer's lock; drop the file we just - // created and degrade to a lock-free refresh rather than hold an unverifiable lock. Remove it - // only while it is still the empty file we created: writeLockHolder also returns false when a - // peer stole and restamped this path in the create->stamp gap, and deleting that peer's non-empty - // live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule). Like - // writeLockHolder's read-then-write, this size-check-then-delete is two syscalls, so it narrows - // but does not fully close that window; the residual degrades to the documented double-refresh, - // never a torn credential. - try { - if (Files.size(lock) == 0) { - Files.deleteIfExists(lock); - } - } catch (IOException ignore) { - // gone (a peer moved it during its steal) or unreadable; another acquirer settles the race - } - return null; + // atomic exclusive-create + stamp in one call: no create->stamp gap, so a GC/safepoint pause + // can no longer make our freshly-created lock look empty-and-stale to a peer mid-acquisition + 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 @@ -716,7 +690,15 @@ private String acquireLock(Path lock) { } Os.sleep(LOCK_POLL_SLICE_MILLIS); } catch (IOException e) { - return null; // unexpected IO; degrade to no lock + // the exclusive create may have succeeded and only the nonce write failed, leaving a partial + // lock; best-effort remove it (the create was exclusive, so the file is ours) so it does not + // wedge peers, then degrade to a lock-free refresh + try { + Files.deleteIfExists(lock); + } catch (IOException ignore) { + // a peer's steal moved it, or it is unreadable; the staleness/grace path settles it + } + return null; } } } @@ -784,17 +766,16 @@ private void stealIfStale(Path lock) { return; } } else if (!isOlderThan(lock, Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS, lockStaleMillis))) { - // an empty/unreadable lock is never a validly-held lock (a holder stamps right after creating): it - // is a peer mid-create/stamp (recovers on its own in microseconds) or one a crash orphaned in that - // gap. 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 grace normally dwarfs the create->stamp gap, - // but a pause wider than the grace (a long GC/safepoint or a suspend landing between the two - // syscalls) or a cross-machine clock skew (isOlderThan compares the local clock to the file mtime) - // can still make a freshly-created empty lock look stale and pre-empt a peer mid-stamp. That never - // forges or tears a credential - Layer-1 atomic rename holds, and the pre-empted peer's - // writeLockHolder refuses to overwrite this stamp - it degrades to a concurrent refresh (a re-prompt - // on a rotating-refresh-token IdP), the same best-effort residual inLock already accepts. The - // capture-verify below still confirms the lock is unchanged before completing the steal. + // an empty/unreadable lock is never a validly-held lock: acquireLock creates the lock and writes + // the owner nonce in ONE atomic call (createLockFile via CREATE_NEW), so a live lock always carries + // a stamp. An empty lock therefore means a crash mid-write - the exclusive create succeeded but the + // nonce write did not - a rare, narrow window with NO Java-level create->stamp gap for a GC/safepoint + // pause to straddle (the pause would have to land inside the single write call). 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.) return; } final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp"); 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 index 40f532cea..66e24c42a 100644 --- 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 @@ -99,6 +99,12 @@ public static MockResponse raw(String rawResponse) { return response; } + public static MockResponse dribble() { + MockResponse response = new MockResponse(200, "", true); + response.dribble = true; + return response; + } + public static MockResponse stall() { MockResponse response = new MockResponse(200, "", true); response.stall = true; @@ -283,6 +289,29 @@ private static void writeResponse(OutputStream out, MockResponse response) throw } 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). + 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 { + 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"); @@ -346,6 +375,7 @@ public static class MockResponse { final String body; final boolean chunked; final int status; + boolean dribble; boolean dropConnection; long oversizedBodyBytes; String rawResponse; 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 index c8ea1c563..e8a172644 100644 --- 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 @@ -83,6 +83,42 @@ public void testProtocolDetectionErrorBodyControlAndBidiAreEscaped() throws Exce }); } + @Test(timeout = 30_000) + public void testFlushResponseBodyDribbleAbortsOnRequestTimeout() throws Exception { + assertMemoryLeak(() -> { + // A flush whose response BODY dribbles (chunked headers sent, then the chunk-size line one byte at + // a time, never completing) must abort the read on the configured request timeout: the no-arg + // recv() the flush uses now 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. + try (MockOidcServer server = new MockOidcServer((method, path, body) -> 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(0) // give up after the first aborted read, not retry to a deadline + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + long startNanos = System.nanoTime(); + try { + sender.flush(); + Assert.fail("expected the dribbled response-body read to abort the flush"); + } catch (LineSenderException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + // aborted on the ~1s whole-read bound. The mock dribbles for ~10s, so the old per-read + // re-arm behavior 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("aborted too fast to be the 1s read bound: " + elapsedMillis + "ms", elapsedMillis >= 500); + Assert.assertTrue("aborted too slowly - re-armed per-read instead of bounding the whole read? " + elapsedMillis + "ms", elapsedMillis < 5_000); + } + } + } + }); + } + @Test(timeout = 30_000) public void testServerAuthErrorBodyControlAndBidiAreEscaped() throws Exception { assertMemoryLeak(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 30acd21bb..c4db51bb5 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -374,14 +374,15 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i - **Use a lock *file*, not an OS advisory lock.** Java `FileLock` maps to `fcntl` POSIX record locks on Unix while Python's `fcntl.flock` is BSD `flock`; the two **do not interoperate on Linux**. A lock file acquired with `O_CREAT|O_EXCL` (Java - `Files.createFile`, Python `os.open(..., O_CREAT|O_EXCL)` / `open(p,"x")`) is a plain - filesystem primitive that interoperates trivially. The contract mandates the lock-file - scheme; OS advisory locks are out. + `FileChannel.open(..., CREATE_NEW, WRITE)`, Python `os.open(..., O_CREAT|O_EXCL|O_WRONLY)` + / `open(p,"x")`) is a plain filesystem primitive that interoperates trivially. The contract + mandates the lock-file scheme; OS advisory locks are out. - **Lock file:** `.lock` beside the token file, containing a unique per-acquisition owner stamp — the holder's `pid@host`, a creation timestamp, and a random nonce. - Acquire by exclusive-create; on contention, spin with short backoff up to a small - acquire budget (~3s); if it still cannot be acquired, **proceed without it** (degrade to - Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes) + Acquire by an exclusive-create that writes the owner stamp in the SAME atomic open (create + and stamp are one operation, not two — see the empty-lock note below); on contention, spin + with short backoff up to a small acquire budget (~3s); if it still cannot be acquired, + **proceed without it** (degrade to Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned and stolen, so a crashed holder cannot wedge others. The window must dominate the worst-case time a live holder can hold the lock. That worst case has two parts: the refresh I/O under the lock — send + await + parse, plus a body drain on a parse @@ -395,23 +396,21 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i raises the HTTP timeout must raise this window in step. A client MUST NOT advertise a tighter guarantee than this (an earlier draft claimed ~480s alone, omitting the connection phase). - **An empty/unstamped lock is reclaimable on a short grace, not the full staleness window.** - Acquire is exclusive-create followed by a separate stamp write, so a holder that crashes - between the two leaves an empty `.lock` whose mtime is fresh — which the staleness check - would protect for the whole window, wedging peers into lock-free refreshes. Treat a lock that - carries no readable owner stamp as stealable once it is older than a short grace (a few - seconds) instead of the full window. The grace normally dwarfs the create→stamp gap - (microseconds), but cannot be guaranteed to: a pause wider than the grace (a long GC/safepoint - or a process suspend landing between the two syscalls) or a cross-machine clock skew (the age - check compares the local clock against the file's mtime) can make a freshly-created empty lock - look stale and let a peer pre-empt a holder mid-stamp. This never forges or tears a credential — - Layer 1's atomic replacement always holds — it degrades to a concurrent refresh (a re-prompt on - a rotating-refresh-token IdP), the same best-effort residual as running lock-free. To keep that - residual bounded, the **stamp write and the stamp-failure cleanup verify ownership** the way - release does: a holder stamps only a lock still empty (never overwriting a stamp a peer wrote - while the holder was pre-empted), and on a stamp failure drops only a lock still empty (never a - peer's non-empty live lock by bare path). The capture-then-verify steal below still aborts if - the lock acquires a stamp in the gap. The Python client MUST mirror this empty-lock grace, and - SHOULD mirror the ownership-verified stamp write. + Acquire writes the owner stamp in the SAME atomic exclusive-create (one `O_CREAT|O_EXCL` open, + then the nonce), so a LIVE lock always carries a stamp and there is **no create→stamp gap** for + a GC/safepoint pause to straddle. An empty `.lock` can therefore arise only from a crash + mid-write — the exclusive create succeeded but the nonce write did not — a rare, narrow window + entirely inside the single write call (no bytecode boundary between two separate syscalls where + a pause is reported). Its mtime is fresh, which the staleness check would protect for the whole + window, wedging peers into lock-free refreshes; so treat a lock that carries no readable owner + stamp as stealable once it is older than a short grace (a few seconds) instead of the full + window. A cross-machine clock skew wider than the grace (the age check compares the local clock + against the file's mtime) could still pre-empt such a partial lock, but that never forges or + tears a credential — Layer 1's atomic replacement always holds — it degrades to a concurrent + refresh (a re-prompt on a rotating-refresh-token IdP), the same best-effort residual as running + lock-free. The capture-then-verify steal below still aborts if the captured lock does not match + what was judged stale. The Python client MUST mirror this atomic create-with-stamp and the + empty-lock grace. - **Release verifies ownership.** A holder releases by re-reading the lock and deleting it **only when it still carries that holder's own owner stamp**, never by bare path. Should a hold ever outrun the staleness window and be stolen and recreated by a peer, the From 82b8bab7d2df91d0e46bf421fe8fbd3c1b69c3a8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 15 Jul 2026 01:03:27 +0100 Subject: [PATCH 083/192] Fix OIDC cancelRow crash and SF drainer terminal The httpTokenProvider work introduced two defects. cancelRow() could segfault the JVM. With a provider configured, newRequest() defers the token and leaves the request at the header stage, so contentStart stays at its -1 sentinel and no row bytes are buffered. cancelRow() then ran trimContentToLen(0), which set the write pointer to -1, and the next buffer write faulted in Unsafe.putByte. cancelRow() now returns early while the token is pending (nothing is buffered yet), and trimContentToLen refuses the -1 sentinel as a defensive guard. The store-and-forward background drainer could terminate a producer on a transient credential outage. It bounded a token-provider failure by reconnect_max_duration_millis and then latched a SECURITY_ERROR terminal, dropping a producer that store-and-forward had promised to keep alive. A failing provider (IdP unreachable, a silent refresh failing, an interactive sign-in in progress) is a transient outage like any other, so the running drainer now retries it indefinitely with capped backoff, per Invariant B. The foreground/SYNC initial connect still fails fast, because a connectivity error is only the caller's problem during initialization. This restores the file's own field comment, which already declared reconnect_max_duration_millis "NOT consulted by the background loop". The crash gets a regression test proven to segfault without the guard. The drainer tests now assert the sender survives a persistent provider outage and recovers, replacing a catch-all that asserted nothing about the terminal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/http/client/HttpClient.java | 7 ++ .../line/http/AbstractLineHttpSender.java | 9 ++ .../sf/cursor/CursorWebSocketSendLoop.java | 80 ++++-------- .../line/LineHttpSenderTokenProviderTest.java | 43 +++++++ .../client/WebSocketTokenProviderTest.java | 116 ++++++++++-------- 5 files changed, 149 insertions(+), 106 deletions(-) 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..4ac4eb120 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 @@ -548,6 +548,13 @@ public String toString() { } 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; } 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 06db4b4a8..a7409983f 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 @@ -469,6 +469,15 @@ public DirectByteSlice bufferView() { @Override public void cancelRow() { validateNotClosed(); + if (isTokenPending) { + // newRequest() left the request at the header stage with the provider token deferred, so + // withContent() has not run and contentStart is still -1 (getContentLength() reads 0): no row + // bytes were written, so there is nothing to trim. trimContentToLen(0) would set the write + // pointer to contentStart + 0 == -1 and the next buffer write would segfault. Just reset the + // row state and leave the token pending for the next row. + state = RequestState.EMPTY; + return; + } request.trimContentToLen(rowBookmark); state = RequestState.EMPTY; } 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 1c054b25b..264912221 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 @@ -1138,18 +1138,16 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM // INVARIANT B: a store-and-forward drainer 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. The ONLY terminal conditions are a genuinely - // non-retriable upgrade (auth / non-421 upgrade / durable-ack capability - // gap), which return directly below, a credential the client cannot - // ACQUIRE (QwpCredentialUnavailableException -- see its catch below), or - // the sender being stopped. SF exhaustion is surfaced to the PRODUCER as - // append backpressure, never here. reconnect_max_duration_millis is - // intentionally NOT consulted for a TRANSPORT outage: for those it bounds - // only the blocking (non-lazy) initial connect in - // QwpWebSocketSender.buildAndConnect, never this background loop. It does - // bound an uninterrupted run of credential-acquisition failures, which no - // amount of retrying can clear. + // 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. The ONLY terminal conditions are a genuinely non-retriable + // upgrade (auth / non-421 upgrade / durable-ack capability gap), which + // return directly below, or the sender being stopped. SF exhaustion is + // surfaced to the PRODUCER as append backpressure, never here. + // reconnect_max_duration_millis is intentionally NOT consulted anywhere + // in this background loop: it bounds only the blocking (non-lazy) initial + // connect in QwpWebSocketSender.buildAndConnect, never this loop. long backoffMillis = reconnectInitialBackoffMillis; if (paceFirstAttemptMillis > 0 && running) { // NACK-initiated recycle against a reachable server: pace the @@ -1170,11 +1168,6 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM int attempts = 0; long lastLogNanos = 0L; Throwable lastReconnectError = initial; - // Start of the current UNINTERRUPTED run of credential-acquisition failures; 0 when none is in - // flight. Any other outcome (a connect, or a transport-class failure) clears it, so only a - // sustained inability to obtain a credential burns the budget and terminates -- a token blip - // between transport errors still recovers. - long credentialFailingSinceNanos = 0L; while (running) { attempts++; totalReconnectAttempts.incrementAndGet(); @@ -1271,52 +1264,26 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM dispatchError(err); return; } catch (QwpCredentialUnavailableException e) { - // The token provider threw instead of returning a credential. Unlike a transport outage - // (Invariant B), retrying alone cannot clear this: the loop cannot conjure a token the - // provider will not hand over. But a silent refresh can fail transiently, so retry while - // one still might recover -- bounded by the reconnect budget -- and only terminate once - // credential acquisition has failed for that whole uninterrupted window. Terminating is - // what keeps a dead credential ("not signed in") from reconnect-looping forever behind - // throttled logs: it surfaces the provider's own message to the producer via checkError() - // and to the async handler, exactly as a server-side 401/403 does above. - long now = System.nanoTime(); - if (credentialFailingSinceNanos == 0L) { - credentialFailingSinceNanos = now; - } - // Compare in millis, not nanos: the builder puts no upper bound on the budget, and a - // millis-to-nanos multiply of a large one overflows to a negative bound -- which would - // terminate on the FIRST credential blip instead of never. - if ((now - credentialFailingSinceNanos) / 1_000_000L > reconnectMaxDurationMillis) { - LOG.error("token provider failed for {}ms during {} -- won't retry: {}", - reconnectMaxDurationMillis, phase, e.getMessage()); - long fromFsn = engine.ackedFsn() + 1L; - long toFsn = Math.max(fromFsn, engine.publishedFsn()); - SenderError err = new SenderError( - SenderError.Category.SECURITY_ERROR, - SenderError.Policy.TERMINAL, - SenderError.NO_STATUS_BYTE, - "token-provider-failed: " + e.getMessage(), - SenderError.NO_MESSAGE_SEQUENCE, - fromFsn, - toFsn, - null, - System.nanoTime() - ); - totalServerErrors.incrementAndGet(); - recordFatal(new LineSenderServerException(err)); - dispatchError(err); - return; - } + // 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. lastReconnectError = e; + long now = System.nanoTime(); if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) { - LOG.warn("{} attempt {}: the token provider failed ({}); retrying within the " - + "credential budget -- if this persists the sender terminates", + 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) { - credentialFailingSinceNanos = 0L; // Role mismatch: every reachable endpoint role-rejected the // upgrade -- right now they are all replicas / primary-catchup. // This is a TRANSIENT failover window (a replica is promotable), @@ -1355,7 +1322,6 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM recordFatal(e); throw (Error) e; } - credentialFailingSinceNanos = 0L; lastReconnectError = e; long now = System.nanoTime(); if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) { 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 index 2d08f8ef4..5120227d2 100644 --- 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 @@ -87,6 +87,49 @@ public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception { }); } + @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(() -> { 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 index b947f87d5..6f3e63725 100644 --- 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 @@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -270,13 +271,18 @@ public void testThrowingProviderOnReconnectIsRetriedAndRecovers() throws Excepti }); } - @Test - public void testPersistentlyThrowingProviderOnReconnectTerminatesTheSender() throws Exception { + @Test(timeout = 60_000) + public void testPersistentlyThrowingProviderOnReconnectDoesNotTerminateAndRecovers() throws Exception { assertMemoryLeak(() -> { - // The complement to the recover-on-transient case: a provider that keeps throwing on every reconnect - // must NOT be silently swallowed on the background I/O thread - once the (short here) reconnect - // budget is exhausted the sender terminates, and a subsequent send/flush must surface the failure - // rather than block forever or silently drop data. A cap on the reconnect duration keeps this fast. + // 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)) { @@ -284,53 +290,65 @@ public void testPersistentlyThrowingProviderOnReconnectTerminatesTheSender() thr 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(300) // short budget so persistent failure terminates quickly + .reconnectMaxDurationMillis(budgetMillis) .httpTokenProvider(() -> { int n = calls.incrementAndGet(); - if (n == 1) { - return "TOKEN-1"; // initial connect succeeds + if (providerFailing.get()) { + throw new OidcAuthException("persistent: not signed in"); } - throw new OidcAuthException("persistent: not signed in"); // every reconnect pull fails + 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 -> the reconnect keeps failing + // 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); - // once the reconnect budget exhausts, the terminated sender must surface the failure on a - // later send/flush (never a silent success), so drive rows until one throws - waitFor(() -> { - try { - sender.table("foo").longColumn("v", 2L).atNow(); - sender.flush(); - return false; // not terminated yet - the reconnect is still within budget - } catch (Exception e) { - return true; // terminated: the persistent provider failure surfaced, as intended - } - }, 15_000); - Assert.assertTrue("the provider must have been re-queried on the failing reconnect, got " + calls.get(), - calls.get() >= 2); + // 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 = 30_000) - public void testCredentialFailureTimerResetsAcrossTransientReconnectFailures() throws Exception { + @Test(timeout = 60_000) + public void testCredentialFailuresInterleavedWithRoleRejectsDoNotTerminateAndRecover() throws Exception { assertMemoryLeak(() -> { - // The SF credential terminal budget must accumulate only across an UNINTERRUPTED run of - // credential-acquisition failures: a transient reconnect failure (here a 421 role reject) between - // credential blips must RESET the timer, so a provider that fails only intermittently - interleaved - // with transport/role failures - never terminates the sender even when the total credential-failing - // span far exceeds the reconnect budget. Without the reset (credentialFailingSinceNanos = 0L on the - // transport / role-mismatch catch) the credential blips accumulate and terminate the sender at the - // budget, so batch 2 would never land. + // 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)) { @@ -338,7 +356,7 @@ public void testCredentialFailureTimerResetsAcrossTransientReconnectFailures() t server.start(); Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - long budgetMillis = 800; // short: WITHOUT the reset, accumulation terminates well before recovery + long budgetMillis = 300; // short: the interleaved outage below far exceeds it try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) .address("localhost:" + port) .reconnectInitialBackoffMillis(20) @@ -347,10 +365,9 @@ public void testCredentialFailureTimerResetsAcrossTransientReconnectFailures() t .httpTokenProvider(() -> { int n = calls.incrementAndGet(); // call 1: the initial connect (must succeed). Then alternate on every reconnect - // attempt: even calls THROW (a credential blip -> timer starts), odd calls RETURN a - // valid token (whose connect then hits the transient 421 role reject below -> the - // role-mismatch catch RESETS the timer). So credential blips and role rejects strictly - // alternate: the timer is set then reset each cycle and never approaches the budget. + // 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"); } @@ -359,24 +376,25 @@ public void testCredentialFailureTimerResetsAcrossTransientReconnectFailures() t .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 the - // reconnect attempts deterministically hit it (no race where a reconnect succeeds first). The - // already-established initial connection is unaffected and still ships batch 1. + // 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"); - // batch 1 lands on the established connection and is ACKed, then the server drops the socket - // -> the background reconnect loop starts and enters the credential-blip / role-reject cycle 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; if the reset - // were broken the credential blips would accumulate and terminate the sender by now - Thread.sleep(budgetMillis * 3); + // 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 attempt now succeeds. The sender must - // still be alive (never terminated during the reject phase), so batch 2 lands. + // clear the reject: the next token-returning reconnect now succeeds and batch 2 drains server.setRejectWithRole(null); - sender.table("foo").longColumn("v", 2L).atNow(); 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(), From 3250b3e53bcf3194e2fd6741df24ed5bacc63998 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 15 Jul 2026 01:16:45 +0100 Subject: [PATCH 084/192] Harden OIDC token validation, lock and issuer-path pin Three defects in the OIDC token-provider work. The ILP HTTP sender skipped token validation whenever the provider returned the same CharSequence instance it had validated before, on the theory that an instance never changes content. HttpTokenProvider makes no such promise: a provider that reuses one buffer (the idiomatic zero-alloc style) and mutates it in place between flushes had its mutated token spliced verbatim into the Authorization header, which request.authToken writes with no CR/LF filtering - a header-injection bypass of the very check validateToken exists to enforce. The sender now validates every pulled token; the scan is O(token length) and is dwarfed by the flush's network round-trip, matching what the WebSocket auth path already does on every pull. getToken() acquired its lock with the interruptible timed tryLock even on the uncontended fast path. That overload throws InterruptedException the moment the calling thread merely carries a set interrupt flag - even on a free lock - and the handler re-arms the flag, so every later getToken() on that thread failed with a valid token sitting in the cache. ILP producers commonly run on pooled or managed threads where interrupt is the standard cancellation signal. An untimed tryLock now handles the uncontended case; the timed poll remains only for genuine contention behind a peer's silent refresh. The issuer-path pin rejected a "." or ".." path segment but not "..;": a server or proxy that strips RFC 3986 matrix parameters resolves /realms/acme/..;/evil to /realms/evil, a different realm on the same host, redirecting the device code and refresh token to a sibling tenant. The dot-segment check now strips a ";suffix" from each decoded segment before comparing. Each fix gets a regression test proven to fail when the fix is reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 39 ++++++++---- .../line/http/AbstractLineHttpSender.java | 22 +++---- .../test/cutlass/auth/OidcDeviceAuthTest.java | 60 +++++++++++++++++++ .../line/LineHttpSenderTokenProviderTest.java | 46 ++++++++++++-- 4 files changed, 138 insertions(+), 29 deletions(-) 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 index 50680af53..a33214536 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -797,7 +797,16 @@ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issu // 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++) { - if (".".equals(endpointSegs[i]) || "..".equals(endpointSegs[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; } } @@ -1036,15 +1045,25 @@ private static String wellKnownUrl(String issuer) { } private void acquireForGetToken() { - // 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 by httpTimeoutMillis so a stuck or pathologically slow holder degrades to - // a retryable failure instead of stalling the flush path without bound. + 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 by httpTimeoutMillis so a stuck or pathologically + // slow holder degrades to a retryable failure instead of stalling the flush path without bound. final long deadline = System.currentTimeMillis() + httpTimeoutMillis; while (true) { throwIfClosed(); 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 a7409983f..d2d658e8b 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 @@ -94,9 +94,6 @@ public abstract class AbstractLineHttpSender implements Sender { private boolean isTokenPending; private JsonErrorParser jsonErrorParser; private boolean lastFlushFailed; - // the last provider token instance validated in stampTokenIfPending(); lets an unchanged multi-KB token - // skip re-validation (a full scan) on every flush. Identity only, never dereferenced for content. - private CharSequence lastValidatedToken; private long pendingRows; private int rowBookmark; private RequestState state = RequestState.EMPTY; @@ -835,19 +832,14 @@ private void stampTokenIfPending() { // 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. validateToken (which scans the whole token) is skipped when the provider returned the - // same instance already validated: an unchanged multi-KB id token is otherwise re-scanned every - // flush. A provider returning the same instance must not mutate its content (HttpTokenProvider - // contract), so the skip cannot let a changed token bypass validation. + // 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 token = httpTokenProvider.getToken(); - // always validate a null token (it must be rejected, and null is the initial lastValidatedToken - // value); otherwise skip re-validation only for the exact same instance already validated. - // lastValidatedToken is assigned only after a successful validation, so it never holds a rejected - // (null/blank/bad-char) token - a re-returned bad token is therefore re-validated and re-rejected. - if (token == null || token != lastValidatedToken) { - HttpTokenProvider.validateToken(token); - lastValidatedToken = token; - } + HttpTokenProvider.validateToken(token); request.authToken(token); request.withContent(); rowBookmark = request.getContentLength(); 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 index fd3bcf378..666c726ca 100644 --- 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 @@ -1541,6 +1541,38 @@ public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception { }); } + @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(() -> { @@ -1971,6 +2003,34 @@ public void testIssuerPathScopingRejectsEncodedTraversal() throws Exception { }); } + @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(() -> { 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 index 5120227d2..fdddf4ada 100644 --- 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 @@ -133,10 +133,10 @@ public void testCancelRowWithPendingTokenDoesNotCorruptRequest() throws Exceptio @Test(timeout = 30_000) public void testChangedProviderTokenIsRevalidated() throws Exception { assertMemoryLeak(() -> { - // the per-flush token validation is skipped only for the SAME instance already validated, so a - // token that CHANGES to a bad one must still be re-validated and rejected - the identity guard must - // not cache a previously-valid result past a token change. First flush a valid token, then return a - // CR/LF token and require the next flush to reject it rather than splice it onto the wire. + // 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 @@ -211,6 +211,44 @@ public void testFailedFlushReSendsSameTokenWithoutRePull() throws Exception { }); } + @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 testNullOrEmptyProviderTokenIsRejected() throws Exception { assertMemoryLeak(() -> { From b7bb36de600279c3da49dbbae2c21c950161472d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 15 Jul 2026 02:08:44 +0100 Subject: [PATCH 085/192] Harden OIDC token flow, store lock, name escaping A batch of moderate review findings across the OIDC device flow, the token store, the ILP-over-HTTP flush path, and the QWP senders, plus the test-quality issues they surfaced. OidcDeviceAuth: - getToken()'s peer-wait budget was one httpTimeoutMillis, but a lock holder doing a silent refresh can legitimately hold for up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE times that, so a concurrent caller threw on a refresh that was going to succeed. The peer now waits the holder's own worst-case hold. - getToken() no longer forecloses a silent refresh when the served-kind token is null but a refresh token exists (the partial grant a groups-in-token sign-in with no id_token leaves behind): it attempts the refresh instead of throwing "no token has been obtained yet". - close()'s javadoc claimed it returns after at most one HTTP request timeout; corrected to describe the in-flight refresh's real worst case, an OS-bounded connect stall. FileTokenStore: - inLock() now serializes same-identity critical sections within one JVM with a process-wide lock, so two OidcDeviceAuth instances for one identity cannot both run the read-refresh-write when the cross-process file lock degrades and double-POST the same rotating refresh token, which a reuse-detecting IdP revokes the whole family for. - warnNoPosixPermsOnce/warnPersistence log via SLF4J instead of System.err, so a host application can filter and redirect them. ILP over HTTP: - The response-body reads inherited the raw request_timeout instead of the per-flush budget (base plus the throughput extension), so a tuned-low request_timeout with request_min_throughput could abort a large, still-progressing chunked error body and turn it into a retry of a non-retryable status. The body reads now use the per-flush budget. QWP: - A rejected table or column name was spliced raw into the error message (QwpWebSocketSender, QwpUdpSender, QwpTableBuffer); it now routes through putAsPrintable, matching the ILP name/error render, so a BOM/bidi/control char in a hostile name cannot reorder or forge the displayed text. Tests: - A swallowed Assert.fail on a mock-server thread, a mutual-exclusion test that passed if a contender died silently (now with a barrier and a run counter), a vacuous enum valueOf cross-check, and a sleep-gated negative assertion that could pass before its waiter thread started are all fixed to assert on the main thread and to prove the thread is genuinely blocked. - Added coverage for a unicode escape at the end of a JSON value, the TokenStore.inLock default, putRawMessage's token stamp, and the null-served-kind refresh; each new production fix's test is proven to fail when the fix is reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 70 +++++++++++++------ .../client/cutlass/auth/OidcDeviceAuth.java | 44 ++++++++---- .../line/http/AbstractLineHttpSender.java | 37 +++++----- .../cutlass/qwp/client/QwpUdpSender.java | 5 +- .../qwp/client/QwpWebSocketSender.java | 5 +- .../cutlass/qwp/protocol/QwpTableBuffer.java | 11 +-- .../test/SenderBuilderErrorApiTest.java | 12 ++-- .../test/cutlass/auth/FileTokenStoreTest.java | 38 ++++++++-- .../auth/OidcDeviceAuthPersistenceTest.java | 43 +++++++++--- .../test/cutlass/auth/OidcDeviceAuthTest.java | 50 ++++++++++++- .../test/cutlass/json/JsonLexerTest.java | 1 + .../line/LineHttpSenderTokenProviderTest.java | 27 +++++++ .../cutlass/qwp/client/QwpUdpSenderTest.java | 33 +++++++++ 13 files changed, 296 insertions(+), 80 deletions(-) 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 index 7e1b377c0..e8c6fad36 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -34,6 +34,9 @@ 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.lang.management.ManagementFactory; import java.nio.ByteBuffer; @@ -57,7 +60,9 @@ 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 identity under a directory, with the @@ -131,6 +136,7 @@ public final class FileTokenStore implements TokenStore { 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; @@ -144,6 +150,14 @@ public final class FileTokenStore implements TokenStore { // attacker-writable directory as the token file, and a real owner stamp (pid@host + millis + UUID) is a // few hundred 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, keyed on the identity fingerprint. 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. Bounded by identity + // count (a handful), so it never grows unbounded. + 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 @@ -255,27 +269,39 @@ public void clear(TokenStoreKey key) { @Override public boolean inLock(TokenStoreKey key, CriticalSection action) { - 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; + // 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. + final ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(key.hash(), k -> new ReentrantLock()); + processLock.lock(); try { - ensureDirectory(); - lock = lockFile(key); - nonce = acquireLock(lock); - } catch (IOException e) { - // could not prepare the lock directory or file; run without the 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. - nonce = null; - } - try { - return action.run(); - } finally { - if (nonce != null) { - releaseLock(lock, nonce); + 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; + try { + ensureDirectory(); + lock = lockFile(key); + nonce = acquireLock(lock); + } catch (IOException 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. + nonce = null; + } + try { + return action.run(); + } finally { + if (nonce != null) { + releaseLock(lock, nonce); + } } + } finally { + processLock.unlock(); } } @@ -638,9 +664,9 @@ private static void warnNoPosixPermsOnce() { if (!warnedNoPosixPerms.compareAndSet(false, true)) { return; } - System.err.println("questdb client: 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."); + 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 writeAndFlush(Path file, byte[] content) throws IOException { 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 index a33214536..5b1e77171 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -45,6 +45,9 @@ 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.URLEncoder; import java.util.Locale; @@ -145,6 +148,7 @@ public class OidcDeviceAuth implements QuietCloseable { // connect instead). build() requires the FileTokenStore staleness window to exceed this multiple as a floor; // the default window adds ample headroom for a typical connection stall on top of it (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; @@ -436,8 +440,12 @@ public void clearCache() { * {@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, i.e. after at most one HTTP request timeout - * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. The exception is a + * 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 - + * DNS, TCP connect, TLS handshake - is bounded by the OS, not by that timeout, so a black-holed token + * endpoint can hold the lock, and this {@code close()}, for the OS connect timeout (commonly ~2 minutes 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 @@ -509,13 +517,17 @@ public String getToken() { 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. + if (refreshToken != null && tryRefreshCoordinated()) { + return selectToken(); + } if (cachedToken != null) { - if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { - return cachedToken; - } - if (refreshToken != null && tryRefreshCoordinated()) { - return selectToken(); - } 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()"); @@ -1062,9 +1074,15 @@ private void acquireForGetToken() { // 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 by httpTimeoutMillis so a stuck or pathologically - // slow holder degrades to a retryable failure instead of stalling the flush path without bound. - final long deadline = System.currentTimeMillis() + httpTimeoutMillis; + // 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. + final long deadline = System.currentTimeMillis() + + (long) LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE * httpTimeoutMillis; while (true) { throwIfClosed(); if (interactiveSignInInProgress) { @@ -1625,8 +1643,8 @@ private void warnPersistence(String operation, Throwable cause) { // 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()); - System.err.println("questdb client: OIDC token store " + operation - + " failed; continuing without persistence" + (detail != null ? " [" + detail + ']' : "")); + LOG.warn("OIDC token store {} failed; continuing without persistence{}", + operation, detail != null ? " [" + detail + ']' : ""); } /** 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 d2d658e8b..a24a17c1d 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 @@ -306,7 +306,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(); @@ -595,13 +597,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()); } } @@ -642,13 +644,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 } } @@ -713,7 +715,10 @@ private void flush0(boolean closing) { response.await(remainingMillis); DirectUtf8Sequence statusCode = response.getStatusCode(); if (isSuccessResponse(statusCode)) { - consumeChunkedResponse(response); // if any + // bound the body drain by the whole per-flush budget (base + throughput extension), 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 + consumeChunkedResponse(response, actualTimeoutMillis); // if any if (keepAliveDisabled(response)) { // Server has HTTP keep-alive disabled, and it's closing this TCP connection. client.disconnect(); @@ -734,13 +739,13 @@ 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 (HttpClientException e) { // this is a network error, we can retry lastFlushFailed = true; @@ -848,16 +853,16 @@ private void stampTokenIfPending() { } } - private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable) { + private void throwOnHttpErrorResponse(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) { // sanitize the raw server body before it reaches the exception message (and any log/terminal): @@ -874,13 +879,13 @@ 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(); - chunkedResponseToSink(response, sink); + 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) @@ -1081,10 +1086,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); @@ -1092,7 +1097,7 @@ 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()); } // sanitize the raw server body before it reaches the exception message (and any log/terminal): 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 964204d09..f0a1263d6 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 @@ -3838,7 +3838,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/protocol/QwpTableBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java index 0947e5e11..585806086 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/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java index 66708ba38..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,13 @@ public void testConnectStringRejectsConnectionListenerInboxCapacityOnNonWebSocke @Test public void testCategoryAndPolicyAreStillEnumerable() { - // Cross-check that the user-facing SenderError enum surface is intact. valueOf(name) throws at - // RUNTIME if a constant is renamed or removed, so this actually fails on a regression - unlike - // asserting a compiled constant reference is non-null, which the compiler already guarantees. - Assert.assertEquals(SenderError.Category.SCHEMA_MISMATCH, SenderError.Category.valueOf("SCHEMA_MISMATCH")); - Assert.assertEquals(SenderError.Policy.RETRIABLE, SenderError.Policy.valueOf("RETRIABLE")); + // 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 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 index 759588245..1938f2479 100644 --- 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 @@ -45,8 +45,10 @@ import java.nio.file.attribute.FileTime; import java.nio.file.attribute.PosixFilePermissions; import java.util.Arrays; +import java.util.concurrent.CyclicBarrier; 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; @@ -503,15 +505,19 @@ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception { Path dir = storeDir(); Files.createDirectories(dir); TokenStoreKey key = sampleKey(); - // two instances over one directory model two processes; a generous acquire budget makes a - // contender wait for the lock 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 + // 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); @@ -520,16 +526,38 @@ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception { } Os.sleep(200); inside.decrementAndGet(); + ran.incrementAndGet(); return true; }; - Thread tA = new Thread(() -> storeA.inLock(key, section)); - Thread tB = new Thread(() -> storeB.inLock(key, section)); + // 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(); tA.join(); tB.join(); + // 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()); }); 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 index 1ed9c7033..3f69a1279 100644 --- 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 @@ -35,8 +35,6 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -224,6 +222,35 @@ public void testClearCacheDoesNotReloadStaleEntry() throws Exception { }); } + @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(timeout = 30_000) public void testGetTokenAsFirstCallAfterRestore() throws Exception { assertMemoryLeak(() -> { @@ -512,18 +539,14 @@ public void testSaveFailureIsNonFatal() throws Exception { }; FakeTokenStore fake = new FakeTokenStore(); fake.failSave = true; - PrintStream originalErr = System.err; - ByteArrayOutputStream captured = new ByteArrayOutputStream(); - System.setErr(new PrintStream(captured, true, "UTF-8")); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) { - // the save throws, but the sign-in still yields the valid in-memory token + // 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()); - } finally { - System.setErr(originalErr); } - String err = new String(captured.toByteArray(), StandardCharsets.UTF_8); - Assert.assertTrue("a save failure must warn to System.err: " + err, err.contains("token store save failed")); + // 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); }); } 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 index 666c726ca..74f50affb 100644 --- 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 @@ -1585,6 +1585,7 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except // 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)); @@ -1593,7 +1594,9 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except refreshInFlight.countDown(); try { if (!releaseRefresh.await(30, TimeUnit.SECONDS)) { - Assert.fail("token refresh timeout expired"); + // 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(); @@ -1626,7 +1629,9 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except // a refresh holds the lock now; a second getToken() must WAIT for it, not fail fast AtomicReference waiterResult = new AtomicReference<>(); AtomicReference waiterError = new AtomicReference<>(); + CountDownLatch waiterStarted = new CountDownLatch(1); Thread waiter = new Thread(() -> { + waiterStarted.countDown(); // signal we are about to enter getToken() try { waiterResult.set(auth.getToken()); } catch (Throwable t) { @@ -1635,10 +1640,14 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except }, "oidc-getToken-waiter"); waiter.setDaemon(true); waiter.start(); + Assert.assertTrue("the waiter thread did not start", waiterStarted.await(10, TimeUnit.SECONDS)); 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 - no result and, crucially, no error yet + // give the waiter time to (wrongly) fail fast if it were going to; while the refresh is held it + // must instead still be BLOCKED - proven by isAlive() (a fail-fast throw would have finished the + // thread), so this cannot pass merely because the waiter had not started yet Thread.sleep(500); + Assert.assertTrue("getToken() must still be blocked behind the peer's refresh, not finished", + waiter.isAlive()); 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", @@ -1652,6 +1661,41 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except 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 + try { + auth.signIn(); + Assert.fail("signIn() must reject a grant with no id_token when groups are encoded in the token"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_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()); } }); } 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 dc410272c..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 @@ -676,6 +676,7 @@ public void testStringEscapesAreDecoded() throws Exception { 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) }); 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 index fdddf4ada..9f35db18c 100644 --- 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 @@ -27,6 +27,8 @@ import io.questdb.client.HttpTokenProvider; import io.questdb.client.Sender; 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 org.junit.Assert; import org.junit.Test; @@ -287,6 +289,31 @@ public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() throws Except }); } + @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(() -> { 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) From 2acba17f1b8eb65afb0416bbf8ef568e0b743006 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 15 Jul 2026 12:03:58 +0100 Subject: [PATCH 086/192] Fix OIDC blank-refresh gate and correct docs tryRefresh's hasRequiredToken gated the served kind on length() > 0 while storeTokens folds a whitespace-only token to null via Chars.isBlank. A non-conformant 2xx refresh with a blank access/id token therefore reported success, cached a token storeTokens then nulled, and made signIn() throw "no access_token" instead of falling back to the interactive device flow. Gate hasRequiredToken on !Chars.isBlank so the refresh gate and the cache agree, and add testBlankTokenFromRefreshFallsBackToInteractiveFlow, which fails without the change with the exact "no access_token" error. Correct three stale docs left by the SF-drainer-terminal fix: - QwpCredentialUnavailableException's javadoc described a reconnectMaxDurationMillis-bounded terminate that no path implements; the running store-and-forward drainer retries a credential-unavailable failure indefinitely under Invariant B, and only the foreground/SYNC initial connect fails fast. - Sender.httpTokenProvider's javadoc claimed a WebSocket sender terminates on a sustained token outage; a running SF-backed sender retries token-pull failures indefinitely and recovers, as testPersistentlyThrowingProviderOnReconnect... already proves. - FileTokenStore's createLockFile/acquireLock/stealIfStale comments claimed a single atomic create+stamp with no gap; writeNewFile opens then writes separately, so a GC pause can land in the empty window, which EMPTY_LOCK_STEAL_GRACE_MILLIS covers. Add testProviderTokenReResolvedOnFailoverReconnect, which drives a real QwpQueryClient failover reconnect and asserts reconnectViaTracker re-resolves the token provider so a rotated token reaches the reconnect upgrade, closing the one untested cross-context provider path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 10 +- .../client/cutlass/auth/FileTokenStore.java | 28 ++--- .../client/cutlass/auth/OidcDeviceAuth.java | 13 ++- .../QwpCredentialUnavailableException.java | 16 +-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 35 ++++++ .../QwpQueryClientTokenProviderTest.java | 102 ++++++++++++++++++ 6 files changed, 177 insertions(+), 27 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 7941c93d6..5f24d2c49 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2111,9 +2111,13 @@ public LineSenderBuilder httpToken(String token) { * started, then once per flush. Over WebSocket the initial connection handshake runs during * {@code build()} and queries the provider once for it, then again once per reconnect handshake - so a * refreshed token is presented each time the link is (re)established; an already-established WebSocket - * is not re-authenticated mid-stream. The two transports differ on a sustained token outage: over HTTP - * a failed pull is retried on the next row, but over WebSocket a pull that keeps failing past the - * reconnect budget terminates the sender for good, like any persistent reconnect failure. + * 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 the token must be obtainable when {@code build()} runs (the + * initial handshake fails fast otherwise), 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 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 index e8c6fad36..de8149c83 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -352,11 +352,13 @@ long getLockStaleMillis() { } private static void createLockFile(Path lock, String nonce) throws IOException { - // Exclusively create the lock (O_CREAT|O_EXCL via CREATE_NEW) AND write the owner nonce in a single - // open, so there is NO create->stamp gap for a GC/safepoint pause (or a cross-machine clock skew) to - // straddle and make our freshly-created lock look empty-and-stale to a peer. 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. + // 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); @@ -701,8 +703,9 @@ private String acquireLock(Path lock) { final long deadline = System.currentTimeMillis() + lockAcquireBudgetMillis; while (true) { try { - // atomic exclusive-create + stamp in one call: no create->stamp gap, so a GC/safepoint pause - // can no longer make our freshly-created lock look empty-and-stale to a peer mid-acquisition + // 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) { @@ -792,11 +795,12 @@ private void stealIfStale(Path lock) { return; } } else if (!isOlderThan(lock, Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS, lockStaleMillis))) { - // an empty/unreadable lock is never a validly-held lock: acquireLock creates the lock and writes - // the owner nonce in ONE atomic call (createLockFile via CREATE_NEW), so a live lock always carries - // a stamp. An empty lock therefore means a crash mid-write - the exclusive create succeeded but the - // nonce write did not - a rare, narrow window with NO Java-level create->stamp gap for a GC/safepoint - // pause to straddle (the pause would have to land inside the single write call). Steal it on the + // 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 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 index 5b1e77171..8bb6967fc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1601,12 +1601,15 @@ private boolean tryRefresh() { 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 id - // token - which RFC 6749 permits and many providers do - or carries an error or a non-2xx status - // must fall back to the interactive flow rather than be cached (and later fail in selectToken()) + // 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. boolean hasRequiredToken = (groupsInToken - ? tokenParser.idToken.length() > 0 - : tokenParser.accessToken.length() > 0) + ? !Chars.isBlank(tokenParser.idToken) + : !Chars.isBlank(tokenParser.accessToken)) && isHttpStatusSuccess() && tokenParser.error.length() == 0; if (hasRequiredToken) { 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 index 5d906424a..9f58d2e62 100644 --- 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 @@ -32,13 +32,15 @@ * 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. Neither is a transport outage, so neither heals - * by retrying alone: a reconnect loop cannot conjure a credential it is unable to - * acquire. The cursor send loop therefore retries this class only for as long as a - * transient refresh could still recover -- bounded by {@code reconnectMaxDurationMillis} - * -- and then terminates the sender with the provider's own message, rather than - * reconnect-looping forever against a dead credential (Invariant B, which governs - * genuine transport outages, stays untouched). + * 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. *

    * This is an internal marker that carries the provider's own exception: it exists so * the send loop can tell "the provider failed" apart from "the network failed". A 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 index 74f50affb..04db28a74 100644 --- 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 @@ -1775,6 +1775,41 @@ public void testBlankServedTokenFromWireIsNotServed() throws Exception { }); } + @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(() -> { 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 index be3ea8bfd..d2ca25d70 100644 --- 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 @@ -26,18 +26,28 @@ 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.qwp.websocket.TestWebSocketServer; import org.junit.Assert; import org.junit.Test; +import java.io.IOException; import java.io.OutputStream; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; +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; /** * Unit coverage for {@link QwpQueryClient#withBearerTokenProvider}: header @@ -53,6 +63,20 @@ */ 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 testProviderConflictsWithBasicAuth() { try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerTokenProvider(() -> "tok")) { @@ -126,6 +150,54 @@ public void testProviderSynthesizesBearerHeader() { } } + @Test(timeout = 20_000) + public void testProviderTokenReResolvedOnFailoverReconnect() throws Exception { + // 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 { // drive the REAL connect path (connect() -> resolveAuthorizationHeader -> runUpgradeWithTimeout), @@ -235,4 +307,34 @@ public void testThrowingProviderFailsConnect() throws Exception { } } } + + 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 + } + } + } } From 7ed1aa1f9e7233a4ee1221f7ba728cd6d7f130dd Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 15 Jul 2026 15:05:57 +0100 Subject: [PATCH 087/192] Correct misleading OIDC docs and mislabeled test Address review findings where documentation and one test described behavior the code does not implement. Comments/test-name only; no runtime behavior changes. - QwpWebSocketSender: three comments claimed a token-provider failure is retried within the reconnect budget and then terminates the sender. The actual behavior is the opposite - the foreground/SYNC connect fails fast with the provider's exception, while the running background drainer retries indefinitely (never budget-bounded, never terminal) per store-and-forward Invariant B. Rewrite all three to match. - OidcDeviceAuth.getToken javadoc said the store per-identity lock wait is "a few seconds at most, then proceeds without the lock." That bound is the cross-process file lock; the in-process lock guarding two same-JVM instances of one identity is not time-bounded and can wait out the peer's whole refresh. Clarify the distinction. - AbstractLineHttpSender: reword the drain comment - the per-flush budget bounds each recv() read, not the whole body cumulatively (fine here because the ILP server is trusted). - FileTokenStoreTest: rename testConcurrentStealContentionTwoWayPreservesMutualExclusion to testSameProcessContendersSerializeAndBothStealStaleLock. Its overlaps==0/maxInside==1 assertions are guaranteed by the in-process PROCESS_LOCKS lock, not the file-lock capture-verify the old name claimed, so the cross-process exclusion is masked in a single JVM. Re-comment to document that the cross-process property is inspection-verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 8 +++-- .../line/http/AbstractLineHttpSender.java | 9 ++++-- .../qwp/client/QwpWebSocketSender.java | 29 +++++++++++-------- .../test/cutlass/auth/FileTokenStoreTest.java | 27 ++++++++--------- 4 files changed, 43 insertions(+), 30 deletions(-) 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 index 8bb6967fc..211216af4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -495,8 +495,12 @@ public String getAuthorizationHeaderValue() { * {@link Builder#httpTimeoutMillis(int)} and still failing 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 briefly to acquire the store's per-identity lock - a few - * seconds at most for {@link FileTokenStore}, then it proceeds without the lock - before that round-trip). + * 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 * resolution, the TCP connect and the TLS handshake - is bounded by the OS, not by httpTimeoutMillis, so an 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 a24a17c1d..b1d532fb1 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 @@ -715,9 +715,12 @@ private void flush0(boolean closing) { response.await(remainingMillis); DirectUtf8Sequence statusCode = response.getStatusCode(); if (isSuccessResponse(statusCode)) { - // bound the body drain by the whole per-flush budget (base + throughput extension), 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 + // 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 wall-clock time against an untrusted identity provider). consumeChunkedResponse(response, actualTimeoutMillis); // if any if (keepAliveDisabled(response)) { // Server has HTTP keep-alive disabled, and it's closing this TCP connection. 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 f0a1263d6..1b4ba9b95 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 @@ -145,8 +145,10 @@ public class QwpWebSocketSender implements Sender { // 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 propagates to the connectWithRetry - // reconnect wrapper, which retries it within the reconnect budget and surfaces the provider's message. + // 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; @@ -2811,20 +2813,23 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) { // 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 propagates to the connectWithRetry reconnect wrapper, which retries it - // within the reconnect budget like any other connect failure and surfaces the provider's own message, - // so a transient failed refresh recovers and only a persistent one terminates the sender (this - // transport's documented reconnect model). Mirrors QwpQueryClient, which likewise resolves the - // credential once before its endpoint walk. + // 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. final String authHeader; try { authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get(); } catch (RuntimeException e) { - // Tag the failure CLASS before it reaches a retry loop: a credential we cannot acquire is not a - // transport outage, so the background reconnect loop must not retry it forever under Invariant B - // -- it bounds this class by the reconnect budget and then terminates with the provider's message. - // A foreground connect unwraps this and rethrows the provider's own exception, so build() still - // surfaces the provider's error directly rather than an internal wrapper. + // 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); } while (true) { 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 index 1938f2479..061402849 100644 --- 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 @@ -206,8 +206,8 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { // 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. - // testConcurrentStealContentionTwoWayPreservesMutualExclusion pins the exclusion the atomic capture - // does guarantee, deterministically, with two contenders. + // 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(); TokenStore.CriticalSection section = () -> { @@ -235,7 +235,7 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { } @Test - public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws Exception { + public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); Files.createDirectories(dir); @@ -245,15 +245,16 @@ public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8)); Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000)); - // TWO processes race to steal the one abandoned lock. With exactly two contenders the steal is - // deterministically mutually exclusive: the atomic capture (rename) lets exactly one steal the - // abandoned lock, and once a winner holds a freshly-stamped lock the loser reads that live stamp, - // judges it not stale (the 60s window far exceeds each ~100ms hold) and waits rather than stealing - // it; the empty-lock grace likewise stops the loser stealing the winner's lock in its brief - // create->stamp gap. The three-actor residual stealIfStale documents - a peer recreating the lock - // while a second captures it AND a third claims the freed path - structurally cannot arise with two - // threads, so exclusion holds exactly here (the N-way best-effort path is - // testConcurrentStealContentionDegradesCleanly). + // 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 + // ReentrantLock, which inLock() takes on 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(); @@ -286,7 +287,7 @@ public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws } Assert.assertEquals("every contender must run its critical section", threads, ran.get()); - Assert.assertEquals("the steal must never admit two holders at once", 0, overlaps.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); }); From f2f9cf991bef73b44c5bd7c3c225ade1f182263b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 5 Aug 2026 23:34:11 +0100 Subject: [PATCH 088/192] Validate the row before at() writes any bytes LineHttpSenderV1.at() and LineHttpSenderV2.at() wrote the leading space and the timestamp before atNow() validated the row state, so an at() with no preceding table() emitted those bytes and only then threw. With an httpTokenProvider configured this corrupts the request. newRequest() leaves the request at the header stage - withContent() is deferred until the first row stamps the Authorization header - so the stray bytes land in the HTTP header block, on a line of their own. The next row's "Authorization: Bearer ..." is then appended to that line, which makes it an obs-fold continuation of User-Agent (RFC 7230) rather than a header of its own. The flush ships with no credential at all, the server answers 401, and close() drops the buffered rows because flush0() returns early on lastFlushFailed. cancelRow() cannot undo it: trimContentToLen only rewinds within the content section, and it early-returns while the token is pending anyway. Without a provider the same misuse only wrote into the request body, where cancelRow() cleaned it up, so the deferred-token design is what turned a recoverable API misuse into silent credential and data loss. AbstractLineHttpSender now exposes validateRowStarted(), which both at() overloads call before their first write. atNow() calls it too and then writes the terminator unconditionally - equivalent to the old switch, since RequestState has exactly four constants and the guard rejects two of them. LineHttpSenderV3 inherits V2's at(). A sweep of every public row-building method - 21 methods over two protocol versions - confirms at() was the only write-before-validate path: symbol, the column setters, the array columns, atNow and cancelRow all reject before writing. The regression test drives both at() overloads over V1 and V2 and asserts on the header the mock server actually parsed off the wire, rather than on an exception message. It fails without the production change with "expected: but was:" - null being the symptom that no credential reached the server. Co-Authored-By: Claude Opus 5 (1M context) --- .../line/http/AbstractLineHttpSender.java | 36 +++++++++---- .../cutlass/line/http/LineHttpSenderV1.java | 4 ++ .../cutlass/line/http/LineHttpSenderV2.java | 4 ++ .../line/LineHttpSenderTokenProviderTest.java | 51 +++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) 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 b1d532fb1..a61c5219e 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 @@ -438,17 +438,11 @@ 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; - } + // validateRowStarted() rejects EMPTY and TABLE_NAME_SET, so only ADDING_SYMBOLS and ADDING_COLUMNS + // reach the terminator write + validateRowStarted(); + request.put('\n'); + state = RequestState.EMPTY; if (rowAdded()) { flush(); } @@ -953,6 +947,26 @@ protected void validateColumnName(CharSequence name) { } } + /** + * 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) { 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..f13b01a8f 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,12 +116,16 @@ protected LineHttpSenderV1(ObjList hosts, @Override public void at(long timestamp, ChronoUnit unit) { + // reject before the first write, not in atNow() after it: see validateRowStarted() + validateRowStarted(); request.putAscii(' ').put(NanosTimestampDriver.INSTANCE.from(timestamp, unit)); atNow(); } @Override public void at(Instant timestamp) { + // reject before the first write, not in atNow() after it: see validateRowStarted() + validateRowStarted(); request.putAscii(' ').put(NanosTimestampDriver.INSTANCE.from(timestamp)); atNow(); } 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..7116f8b37 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,6 +163,8 @@ protected LineHttpSenderV2( @Override public void at(long timestamp, ChronoUnit unit) { + // reject before the first write, not in atNow() after it: see validateRowStarted() + validateRowStarted(); request.putAscii(' '); putTimestamp(timestamp, unit); atNow(); @@ -170,6 +172,8 @@ public void at(long timestamp, ChronoUnit unit) { @Override public void at(Instant timestamp) { + // reject before the first write, not in atNow() after it: see validateRowStarted() + validateRowStarted(); request.putAscii(' '); putTimestamp(timestamp); atNow(); 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 index 9f35db18c..d3dea466a 100644 --- 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 @@ -33,6 +33,8 @@ 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; @@ -55,6 +57,55 @@ */ public class LineHttpSenderTokenProviderTest { + @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(() -> { From e0b2037adeade5d9ed3636d6c8946537c8790de1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 5 Aug 2026 23:45:42 +0100 Subject: [PATCH 089/192] Report a credential outage to the error handler The background reconnect loop retries a QwpCredentialUnavailableException indefinitely under Invariant B, which is correct - a token provider hands over a credential again once the IdP is reachable or the user finishes signing in, and the un-acked rows stay safe in store-and-forward. But the credential arm was the only endpoint-policy failure that did not also dispatch a SenderError, so the retry was programmatically invisible. That matters because a credential outage is often NOT self-healing: a revoked refresh token, or an IdP that is permanently unreachable from this host. Meanwhile flush() keeps returning success while SF absorbs the rows, so the only signal is a throttled slf4j WARN - and this library ships embedded, frequently with no binding configured. It then resurfaces much later as ring backpressure, which points the operator at disk sizing instead of at their credentials. The javadoc on dispatchRetriedEndpointPolicyFailure already describes exactly this hazard, and the auth/upgrade and durable-ack arms both dispatch for it. The arm now dispatches a SECURITY_ERROR carrying the provider's own message under a "credential-unavailable: " prefix, matching how the sibling arms label theirs. The policy stays RETRIABLE, never TERMINAL: the handler learns the wire is down while the producer stays alive and no data is at risk. The regression test drives a persistent provider outage on a running sender and asserts the handler observes the category, the RETRIABLE policy and the provider's message, that no terminal is ever latched, and that the sender still drains once the provider recovers. Without the production change it fails on the dispatch wait: "waitFor timed out after 15000ms". Co-Authored-By: Claude Opus 5 (1M context) --- .../sf/cursor/CursorWebSocketSendLoop.java | 10 +++ .../client/WebSocketTokenProviderTest.java | 73 +++++++++++++++++++ 2 files changed, 83 insertions(+) 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 bdf73cc48..e8735ca05 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 @@ -1889,6 +1889,16 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM // 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 -- " 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 index 6f3e63725..63d90d0b6 100644 --- 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 @@ -25,6 +25,7 @@ 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.test.cutlass.qwp.websocket.TestWebSocketServer; import org.junit.Assert; @@ -39,6 +40,7 @@ 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 static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; @@ -271,6 +273,77 @@ public void testThrowingProviderOnReconnectIsRetriedAndRecovers() throws Excepti }); } + @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(() -> { From 5ec1c218085fb6d804bdc59112b609d1fd77f50a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 00:02:56 +0100 Subject: [PATCH 090/192] Let close() break a drainer stuck in a token pull The reconnect walk publishes the WebSocketClient it is about to block on so close() can break it, but the credential pull that now precedes the walk had no such handle. A token provider is caller code owning no socket, so closeTraffic() cannot reach it, and it can block far longer than close()'s 30s budget: OidcDeviceAuth.getToken() waits up to 4 x httpTimeoutMillis (120s by default) behind a peer's silent refresh. The pre-check at the top of buildAndConnect does not cover this. It only rejects a close that already happened, and during an IdP outage the drainer sits inside a pull for most of every retry cycle, so close() lands there routinely rather than in a narrow check-then-act race. It then burned the whole budget and threw "cursor I/O thread did not stop", delegating teardown, on what should have been a clean shutdown. ConnectCancellation now carries the thread that is inside a pull, and cancel() interrupts it. That is the only lever which reaches a Java-level wait: OidcDeviceAuth converts the interrupt into a provider failure, which the reconnect loop already handles as a transient outage before observing the abort and exiting. buildAndConnect publishes the thread before the pull, re-checks cancellation, and clears the marker in a finally, so a later cancel() cannot interrupt the walk at an arbitrary point. The marker is only ever set while a pull is in flight, so a sender with no token provider sees no change at all. This does NOT cover a provider stalled in an OS-level TCP connect, which ignores interrupts; close() still loud-fails on its budget there, as before. The comment at the cancel site says so rather than implying the window is closed. The regression test parks the drainer in a pull that only an interrupt can release, then times close(). With the fix it returns promptly; with the interrupt lever removed it reproduces the defect exactly, failing after 30s with "cursor I/O thread did not stop: close() timed out after 30000ms awaiting shutdown". Co-Authored-By: Claude Opus 5 (1M context) --- .../qwp/client/QwpWebSocketSender.java | 18 +++++ .../sf/cursor/CursorWebSocketSendLoop.java | 36 +++++++++ .../client/WebSocketTokenProviderTest.java | 74 +++++++++++++++++++ 3 files changed, 128 insertions(+) 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 4d158fe21..9a80492b1 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 @@ -3156,6 +3156,18 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo // 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(); @@ -3168,6 +3180,12 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo // 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()) { 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 e8735ca05..fab637623 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 @@ -3632,11 +3632,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()} @@ -3674,6 +3696,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/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 index 63d90d0b6..a9e6b4211 100644 --- 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 @@ -36,6 +36,7 @@ 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; @@ -273,6 +274,79 @@ public void testThrowingProviderOnReconnectIsRetriedAndRecovers() throws Excepti }); } + @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(() -> { From e06be8cb0134abc1bbc67bb495035c71705a2eb7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 00:24:46 +0100 Subject: [PATCH 091/192] Correct the frozen token-store interop contract design/oidc-token-persistence.md is the spec the Python client mirrors, but it had drifted from the code it describes. The schema example wrote both endpoint fields without a port, while this client writes and compares the canon() rendering, where the port is always explicit, with an exact string compare. A peer client following the example 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. Every existing test round-trips through this client's own writer, so nothing caught it. The lock section claimed the owner stamp is written "in the SAME atomic open" and that there is "no create->stamp gap" for a pause to straddle. That is not what the code does, and commit 2acba17f already corrected the source comments to say so: the exclusive create and the stamp write are two operations on one handle, and EMPTY_LOCK_STEAL_GRACE_MILLIS is what stops a peer stealing a lock that is mid-stamp. A client that believed the doc could shorten its grace and start stealing live locks. Corrected, and pinned where the doc was vague: the empty-lock grace is 5 seconds, not "a few"; a lock over 4 KiB reads as unstamped; a temp sweep must skip names containing ".lock.", which are in-flight steal captures. The API section gained the inLock/CriticalSection hook it was missing, lost the equals/hashCode that TokenStoreKey does not have, and now describes clear() running under the cross-process lock. The file NAME hash was already pinned by a golden-value test; the file BODY was not. testFrozenSchemaEndpointsCarryAnExplicitPort closes that: it loads the documented encoding, then loads the same document with the default ports omitted and asserts it is rejected. The two halves differ only in the port, so the rejection can only be the endpoint fingerprint. Left alone: the doc still records System.err as the channel for the persistence-failure warning while the code uses SLF4J. Resolving that means deciding which one is wrong, which is a separate change. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/cutlass/auth/FileTokenStoreTest.java | 34 +++++++++ design/oidc-token-persistence.md | 75 ++++++++++++++----- 2 files changed, 91 insertions(+), 18 deletions(-) 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 index 061402849..423e6b854 100644 --- 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 @@ -426,6 +426,40 @@ public void testFingerprintMismatchReturnsNull() throws Exception { }); } + @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(); + Files.createDirectories(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(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index c4db51bb5..a6b24ae06 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -113,6 +113,22 @@ public interface TokenStore { /** Remove any persisted tokens for this identity. */ void clear(TokenStoreKey key); + + /** Layer 2 (optional): run action while holding the per-identity cross-process lock. + * The default just runs it unlocked, so a store with no cross-process concern stays a + * plain load/save/clear. An implementation that cannot acquire the lock within its + * budget should run action anyway (degrade to Layer 1) rather than fail a sign-in. + * NOT re-entrant: action must not call back into the owning OidcDeviceAuth. */ + default boolean inLock(TokenStoreKey key, CriticalSection action) { + return action.run(); + } + + /** The critical section; its boolean result (whether a valid token resulted) is + * returned by inLock unchanged. */ + @FunctionalInterface + interface CriticalSection { + boolean run(); + } } ``` @@ -127,7 +143,8 @@ public final class TokenStoreKey { private final String scope; private final String audience; // may be null private final boolean groupsInToken; - // getters; equals/hashCode over all fields + // getters only; identity is the hash() below plus the per-field fingerprint compare on + // load, so no equals/hashCode - the key is never used as a hash-map key // hash(): hex SHA-256 of a canonical join of the fields, for use as a file name } ``` @@ -179,7 +196,12 @@ Convenience: `FileTokenStore.atDefaultLocation()` and `FileTokenStore.at(Path di the live config** => return `null` (treat as "no cache"), never throw into the sign-in path. The fingerprint re-check is defence in depth against a copied/renamed/hostile file whose name happens to collide. -- **clear():** `Files.deleteIfExists(target)`. +- **clear():** `Files.deleteIfExists(target)`, run **under the same cross-process lock** as the + read-refresh-write, so a peer's in-flight refresh cannot resurrect the entry by renaming a fresh + file in just after the delete. It returns without creating the directory when nothing is + persisted yet. Cross-process clear stays best-effort: a peer holding a live in-memory token may + legitimately re-persist afterwards. It always forces a fresh sign-in for the calling process, + which resets its in-memory token state regardless. ## Integration into `OidcDeviceAuth` @@ -318,8 +340,8 @@ serving — but it defeats *sharing*, leaving each client to re-prompt). { "v": 1, "client_id": "questdb", - "token_endpoint": "https://idp.example.com/as/token.oauth2", - "device_authorization_endpoint": "https://idp.example.com/as/device_authz.oauth2", + "token_endpoint": "https://idp.example.com:443/as/token.oauth2", + "device_authorization_endpoint": "https://idp.example.com:443/as/device_authz.oauth2", "scope": "openid", "audience": "api://billing", "groups_in_token": false, @@ -335,6 +357,14 @@ serving — but it defeats *sharing*, leaving each client to re-prompt). hash collision or a copied/renamed file). `expires_at_millis` is absolute wall-clock, so it is portable across a restart and across machines that share a clock. + The two endpoint fields MUST carry the same `canon(endpoint)` rendering the file name + hashes — in particular **with the port always explicit**, as in the example above. The + re-check is a byte-exact string compare, not a URL comparison, so a writer that omits the + default port produces a file every other client silently ignores: `load` returns null, the + process re-prompts, and it re-persists in its own encoding, so the two never converge. This + is the one field-level normalization that is load-bearing rather than best-effort — unlike + the hash, where drift only costs a missed share. + A field whose value is null - an absent `audience`, or a token kind the grant did not return (e.g. no `id_token`) - is **omitted entirely**, not written as JSON `null`. QuestDB's `JsonLexer` reports a bare `null` and a quoted `"null"` identically, so omission is the only @@ -379,8 +409,8 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i mandates the lock-file scheme; OS advisory locks are out. - **Lock file:** `.lock` beside the token file, containing a unique per-acquisition owner stamp — the holder's `pid@host`, a creation timestamp, and a random nonce. - Acquire by an exclusive-create that writes the owner stamp in the SAME atomic open (create - and stamp are one operation, not two — see the empty-lock note below); on contention, spin + Acquire by an exclusive-create (`O_CREAT|O_EXCL`) and write the owner stamp through that same + open handle — see the empty-lock note below for the window this leaves; on contention, spin with short backoff up to a small acquire budget (~3s); if it still cannot be acquired, **proceed without it** (degrade to Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned and stolen, so a crashed holder cannot wedge others. The window @@ -396,21 +426,30 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i raises the HTTP timeout must raise this window in step. A client MUST NOT advertise a tighter guarantee than this (an earlier draft claimed ~480s alone, omitting the connection phase). - **An empty/unstamped lock is reclaimable on a short grace, not the full staleness window.** - Acquire writes the owner stamp in the SAME atomic exclusive-create (one `O_CREAT|O_EXCL` open, - then the nonce), so a LIVE lock always carries a stamp and there is **no create→stamp gap** for - a GC/safepoint pause to straddle. An empty `.lock` can therefore arise only from a crash - mid-write — the exclusive create succeeded but the nonce write did not — a rare, narrow window - entirely inside the single write call (no bytecode boundary between two separate syscalls where - a pause is reported). Its mtime is fresh, which the staleness check would protect for the whole - window, wedging peers into lock-free refreshes; so treat a lock that carries no readable owner - stamp as stealable once it is older than a short grace (a few seconds) instead of the full - window. A cross-machine clock skew wider than the grace (the age check compares the local clock + The exclusive create and the stamp write are two operations on one open handle, so the file + **does exist empty** between them. That window is small — no I/O sits between the two — but it + is real, and a GC/safepoint pause or a descheduled thread CAN land in it, as can a crash + mid-write. Its mtime is fresh, which the staleness check would protect for the whole window, + wedging peers into lock-free refreshes; so treat a lock that carries no readable owner stamp + as stealable once it is older than a short grace — **5 seconds**, which must dominate the + create→stamp window on any implementation — instead of the full staleness window. **The grace, + not the absence of the window, is what stops a peer from stealing a lock that is mid-stamp**, so + a client MUST NOT shorten it on the assumption that create-with-stamp is atomic: it is not, in + Java (`FileChannel.open(CREATE_NEW)` then `write`) or in Python (`os.open(O_CREAT|O_EXCL)` then + `write`). A cross-machine clock skew wider than the grace (the age check compares the local clock against the file's mtime) could still pre-empt such a partial lock, but that never forges or tears a credential — Layer 1's atomic replacement always holds — it degrades to a concurrent refresh (a re-prompt on a rotating-refresh-token IdP), the same best-effort residual as running lock-free. The capture-then-verify steal below still aborts if the captured lock does not match - what was judged stale. The Python client MUST mirror this atomic create-with-stamp and the - empty-lock grace. + what was judged stale. The Python client MUST mirror the 5-second empty-lock grace. +- **Bounded lock read.** A `.lock` larger than **4 KiB** is not read; it is treated as + carrying no readable stamp, i.e. as an empty lock subject to the grace above. An owner stamp is + tens of bytes, so a client MUST keep its stamp well under that cap or its live locks will be + stolen after the grace. +- **Temp-file hygiene must not touch steal captures.** A client that sweeps its own stale + `*.tmp` files MUST skip any name containing `.lock.`: the capture-then-verify steal below + renames the lock to `.lock..tmp` while it decides, and deleting another client's + capture breaks its steal. - **Release verifies ownership.** A holder releases by re-reading the lock and deleting it **only when it still carries that holder's own owner stamp**, never by bare path. Should a hold ever outrun the staleness window and be stolen and recreated by a peer, the @@ -435,7 +474,7 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i deadlock. - **SPI shape:** keep `TokenStore` simple for the no-coordination case and add one - optional hook, e.g. `default T inLock(TokenStoreKey, Supplier action)` that just + optional hook, `default boolean inLock(TokenStoreKey, CriticalSection action)` that just runs `action` (no lock). `FileTokenStore` overrides it with the lock-file protocol; `OidcDeviceAuth` wraps its refresh step in `inLock` and does the re-read-then-decide (steps 2–4) as the action body. A store with no cross-process concern stays a plain From a606bceae848489f27cf718a0b20fff5fa902f30 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 10:03:02 +0100 Subject: [PATCH 092/192] Stop a hostile chunk size spinning the read loop AbstractChunkedResponse.recv(int) promises to bound the whole call, and OidcDeviceAuth.parseBody and discardBody rely on that promise against an identity provider they treat as untrusted. The bound did not hold. Numbers.parseHexLong accumulates val << 4 with no overflow check, so a chunk-size line of 16 or more hex digits wraps negative - 8000000000000000 is exactly Long.MIN_VALUE. A negative size matches neither the "size > 0" data branch nor the "size == 0" terminator, so STATE_CHUNK_DATA 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 deadline check lived INSIDE that gate, so the loop never consulted it. The result is an unbounded CPU-burning spin on a size line the server chooses. The spinning thread holds the OidcDeviceAuth instance lock, so close() never returns either. recv(int) now rejects a negative size as a malformed chunk size, which closes the reachable path. The deadline check also moves above the read gate so the bound holds on every pass rather than only on the passes that read; that half is defence in depth for any future state-machine path that does not read, and has no independent test of its own. MockOidcServer.dribble() steps around this deliberately - it dribbles leading-zero hex digits so "the parsed size stays 0 (so nothing overflows)" - which is why the existing bounded-read tests never met it. The regression test feeds one well-formed chunk followed by the overflowing size line, with defaultTimeout = -1 so no deadline can rescue the loop and only rejecting the size can end the call. Without the production change it spins to the 30s @Test timeout and fails with TestTimedOutException. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/client/AbstractChunkedResponse.java | 29 +++++++++--- .../http/client/ChunkedResponseTest.java | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) 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 9e5543c58..6ec3a650e 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 @@ -99,15 +99,21 @@ public Fragment recv(int timeout) { 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(); - 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"); - } - } dataHi += recvOrDie(dataHi, bufHi, callTimeout); } long p; // moving data pointer for scanning buffer @@ -142,6 +148,15 @@ public Fragment recv(int timeout) { chunkSize.of(dataLo, res + 1); try { size = Numbers.parseHexLong(chunkSize.asAsciiCharSequence()); + if (size < 0) { + // parseHexLong accumulates val << 4 with no overflow check, so a chunk-size + // line of 16 or more hex digits (8000000000000000 is the smallest) wraps to a + // negative value. A negative size matches neither the "size > 0" data branch + // nor the "size == 0" terminator below, so the state machine would loop on it + // forever - and the size line is chosen by the server, which for an OIDC + // discovery or token response is untrusted. Reject it as malformed. + throw new HttpClientException("malformed chunk size"); + } consumed = 0; // consume data buffer ignoring chunk size value and its furniture state = STATE_CHUNK_DATA; 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 2a80a9584..17c429417 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 @@ -219,6 +219,51 @@ protected int recvOrDie(long bufLo, long bufHi, int timeout) { } } + @Test(timeout = 30_000) + public void testOverflowingChunkSizeIsRejectedRatherThanSpun() { + // A chunk-size line of 16 or more hex digits overflows Numbers.parseHexLong (val << 4, unchecked) + // to a NEGATIVE size, which 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. defaultTimeout is -1 here on purpose: no deadline can rescue + // this call, so 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. + 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\n8000000000000000\r\nX"; + 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("the first chunk must still be delivered", first); + Assert.assertEquals('A', (char) Unsafe.getUnsafe().getByte(first.lo())); + try { + rsp.recv(); + Assert.fail("expected the overflowing chunk size to be rejected as malformed"); + } catch (HttpClientException e) { + Assert.assertTrue(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 From 17a811f6ba7be30d56bbeed7f24fde6c38067034 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 10:03:20 +0100 Subject: [PATCH 093/192] Keep the token store working under an interrupt FileTokenStore does all its I/O through FileChannel, an InterruptibleChannel: a thread that merely CARRIES a set interrupt flag makes the first read or write throw ClosedByInterruptException, the channel closes mid-operation, and the flag survives. Two callers arrive that way routinely. An ILP producer on a pooled or managed thread uses interrupt as its cancellation signal - acquireForGetToken's own comment calls that the common case, and it re-arms the flag on the contended path with nothing ever clearing it. The sender's I/O thread is interrupted deliberately by close(), to break a drainer stuck in a credential pull. Two consequences followed. inLock's releaseLock read threw and was swallowed, so the .lock file survived its whole staleness window - 10 minutes by default - while every peer degraded to an unserialized refresh, which is the rotating-refresh-token race the lock exists to prevent. And maybeLoadFromStore latched storeLoadAttempted BEFORE the read, so one interrupted load disabled persistence for the whole life of that OidcDeviceAuth: a process owning a good refresh token on disk re-ran the interactive device flow instead, a hard failure for the headless getToken() consumer the feature exists for, not a degraded one. load(), save() and inLock's lock bookkeeping now clear the flag for the duration of their own file I/O and restore it on the way out. The shield deliberately does NOT cover action.run(): that is the caller's token refresh, and an interrupt is precisely the lever close() uses to break it. releaseLock re-reads the flag rather than reusing the value captured before the section, because the interrupt that matters usually arrives during the refresh. maybeLoadFromStore latches only after a read that COMPLETED - a missing or corrupt file still yields null without throwing, so that answer stays definitive and is not re-read on every later call. releaseLock also stops swallowing its IOException silently. A lock it could not delete degrades every peer for the staleness window, which an operator needs a line for rather than unexplained repeated sign-ins. Two smaller fixes ride along, in blocks this change already touched: save()'s cleanup no longer lets a failing deleteIfExists replace the write or rename failure that is unwinding, and FakeTokenStore gains the failLoad hook whose absence left the load-failure path untested. Three regression tests. Without the production change the save fails with ClosedByInterruptException wrapped as "could not persist the OIDC token to the token store", the lock test fails with "inLock must release the lock even when the section leaves the thread interrupted", and the retry test fails with "no token has been obtained yet; call signIn() ...". Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 111 +++++++++++++----- .../client/cutlass/auth/OidcDeviceAuth.java | 12 +- .../test/cutlass/auth/FileTokenStoreTest.java | 65 ++++++++++ .../auth/OidcDeviceAuthPersistenceTest.java | 50 ++++++++ 4 files changed, 206 insertions(+), 32 deletions(-) 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 index de8149c83..99be44687 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -283,6 +283,10 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // 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; + // acquireLock does FileChannel I/O, so shield it from a carried interrupt flag exactly as load() + // does - but shield ONLY the lock bookkeeping, never action.run(). The critical section is the + // caller's own token refresh, and an interrupt is precisely the lever close() uses to break it. + boolean wasInterrupted = Thread.interrupted(); try { ensureDirectory(); lock = lockFile(key); @@ -292,12 +296,28 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // atomic replacement still keeps every reader consistent - only a rotating-refresh-token race // across processes is left unguarded for this one refresh. nonce = null; + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } } try { return action.run(); } finally { if (nonce != null) { - releaseLock(lock, nonce); + // 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); + } finally { + if (wasInterruptedInSection) { + Thread.currentThread().interrupt(); + } + } } } } finally { @@ -307,41 +327,70 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { @Override public PersistedToken load(TokenStoreKey key) { - Path file = tokenFile(key); - byte[] bytes; + // 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 { - 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; + 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(); + } } - return parseAndVerify(key, bytes); } @Override public void save(TokenStoreKey key, PersistedToken token) { - byte[] content = serialize(key, 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 { - ensureDirectory(); - sweepStaleTempFiles(key.hash()); - Path target = tokenFile(key); - Path tmp = createTempFile(key.hash()); - boolean moved = false; + byte[] content = serialize(key, token); try { - writeAndFlush(tmp, content); - replaceTarget(tmp, target); - moved = true; - } finally { - if (!moved) { - Files.deleteIfExists(tmp); + ensureDirectory(); + 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(); } - } catch (IOException e) { - throw new OidcAuthException(e).put("could not persist the OIDC token to the token store"); } } @@ -586,8 +635,14 @@ private static void releaseLock(Path lock, String nonce) { // (or the staleness steal) to reclaim } catch (NoSuchFileException e) { // already gone (stolen and not yet recreated, or removed elsewhere); nothing to release - } catch (IOException ignore) { - // best-effort release; a leftover lock goes stale and the next acquirer steals it + } 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. + LOG.warn("could not release the OIDC token store lock; peers degrade to lock-free refresh until " + + "it goes stale [error={}]", e.getMessage()); } } 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 index 211216af4..781ba5b77 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1216,17 +1216,21 @@ private void maybeLoadFromStore() { if (tokenStore == null || storeLoadAttempted) { return; } - // attempt the disk read once per instance, even if it yields nothing, so a missing or bad file is - // not re-read on every call - storeLoadAttempted = true; PersistedToken token; try { token = tokenStore.load(storeKey); } catch (RuntimeException e) { - // best-effort: a store read failure must not break sign-in + // Best-effort: a store read failure must not break sign-in. Leave storeLoadAttempted UNSET so a + // transient failure is retried on the next 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. 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); } 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 index 423e6b854..8e02d6c8d 100644 --- 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 @@ -651,6 +651,40 @@ public void testInLockReleasesLockWhenActionThrows() throws Exception { }); } + @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(() -> { @@ -714,6 +748,37 @@ public void testLiteralNullStringTokenRoundTrip() throws Exception { }); } + @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(() -> { 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 index 3f69a1279..98350fba0 100644 --- 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 @@ -835,6 +835,48 @@ public void testTamperedServedTokenWithNonAsciiRejectedOnLoad() throws Exception }); } + @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()); + } + } + }); + } + private static OidcDeviceAuth.Builder baseBuilder(MockOidcServer server) { return OidcDeviceAuth.builder() .clientId("questdb") @@ -919,6 +961,10 @@ private static final class FakeTokenStore implements TokenStore { final AtomicInteger loads = new AtomicInteger(); final AtomicInteger locks = new AtomicInteger(); final AtomicInteger saves = new AtomicInteger(); + // 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; @@ -944,6 +990,10 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { @Override public PersistedToken load(TokenStoreKey key) { loads.incrementAndGet(); + if (failLoadTimes > 0) { + failLoadTimes--; + throw new RuntimeException("token store read failed"); + } return loadReturns != null ? loadReturns : stored; } From 14df48b34fa0e225d8677a081716d97dc5789e6d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 10:03:37 +0100 Subject: [PATCH 094/192] Let an orphan drainer ride out a rotating 401 An orphan drainer quarantined its slot on the first 401 and dropped a .failed sentinel, which nothing in production clears - a permanent abandonment verdict on the slot's un-acked rows, plus a DATA_LOSS report. That was right while the Authorization header was a fixed String: the credential is wrong cluster-wide and waiting cannot fix it. This branch changed the header to a Supplier re-derived from the caller's token provider on every sweep, and left every terminal-classification site untouched. Against a rotating credential a 401 can instead 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 - and the next sweep carries a fresh token. The drainer inherits the sender's supplier, because startOrphanDrainers builds its ReconnectSupplier as an inner class of the live sender, so the condition is reachable wherever httpTokenProvider is configured. QwpWebSocketSender.fixedAuthHeader() now tags a constant header, and Sender routes its Basic and static-bearer cases through it, so ReconnectFactory.hasDynamicCredential() can tell the two apart. The drainer's 401 arm rides out DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS sweeps with capped backoff, but only for a rotating credential; a constant one still fails fast on the first sweep exactly as before. The budget stays attempt-counted and deliberately short, and is never reset: a credential that stays rejected must still reach a human rather than pin the slot and a drainer-pool worker forever. This does NOT repair a persistent clock skew. getToken() keeps serving the same cached token, so those sweeps simply exhaust the budget and quarantine - the right end state for a fault that is not healing. The constant's javadoc says so rather than implying the window is closed. Three tests cover it. The rotating case recovers on the third sweep and fails without the production change with "expected same: was not:". The exhaustion case pins the budget's upper end and fails with "the budget must cap the retries expected:<6> but was:<1>". The fixed-credential control passes with and without the change, which is what keeps the settle budget from relaxing auth handling across the board. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/io/questdb/client/Sender.java | 7 +- .../qwp/client/QwpWebSocketSender.java | 57 +++++++++++- .../client/sf/cursor/BackgroundDrainer.java | 66 ++++++++++--- .../sf/cursor/CursorWebSocketSendLoop.java | 18 ++++ .../BackgroundDrainerDurableAckRetryTest.java | 92 +++++++++++++++++++ 5 files changed, 223 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 837ff4266..69985980b 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3419,14 +3419,17 @@ private void appendAddress(String host, int port) { } 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; String header = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); - return () -> header; + return QwpWebSocketSender.fixedAuthHeader(header); } if (httpToken != null) { String header = "Bearer " + httpToken; - return () -> header; + return QwpWebSocketSender.fixedAuthHeader(header); } if (httpTokenProvider != null) { // pull a fresh token at each (re)handshake so a long-lived WebSocket follows token 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 9a80492b1..02fe34eb3 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 @@ -887,6 +887,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(); @@ -2868,10 +2888,6 @@ private static Throwable captureCloseError(Throwable terminalError, Throwable t) return terminalError; } - private static Supplier fixedAuthHeader(String header) { - return header == null ? null : () -> header; - } - private static long maskGeoHashBits(long value, int precisionBits) { return precisionBits >= 64 ? value : value & ((1L << precisionBits) - 1L); } @@ -4484,6 +4500,16 @@ private int dictionaryEntryWireBytes(int 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); + } + private void healPersistedDictionary(PersistedSymbolDict pd) { if (pd == null || !deltaDictEnabled) { return; @@ -5132,6 +5158,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 @@ -5159,6 +5203,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..c14ef0105 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 @@ -92,6 +92,21 @@ public final class BackgroundDrainer implements Runnable { * cluster-wide misconfig hang the drainer forever. */ public static final int DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS = 16; + /** + * How many consecutive {@code 401}/{@code 403} rejections an orphan drainer rides out before it + * quarantines 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. + *

    + * Small on purpose. Its job is to outlast a window that heals on its own - a revocation landing + * mid-flight, an identity provider rotating signing keys, a token that expires during the settle so + * the next pull refreshes it - not to wait out a genuinely revoked grant. A credential that stays + * rejected must still reach a human rather than pin the slot and a drainer-pool worker forever, so + * the budget is attempt-counted and deliberately short. Note it cannot repair a PERSISTENT clock + * skew: the provider keeps serving the same cached token, so those sweeps simply exhaust the budget + * 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; 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 @@ -302,6 +317,11 @@ public WebSocketClient connectWithDurableAckRetry() { // cluster leaves the capability-gap state, later gaps must establish a // fresh consecutive run before quarantine is permitted. int capabilityGapAttempts = 0; + // Consecutive 401/403 sweeps ridden out so far, counted only for a ROTATING credential (see + // DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS). Never reset: unlike the capability-gap episode + // this budget is per-drain, not per-episode, so a credential that alternates rejected/unreachable + // cannot refill it indefinitely and stall the quarantine that an operator needs to see. + int dynamicCredentialAuthAttempts = 0; // 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). @@ -338,17 +358,41 @@ 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 in seconds. Ride out a small bounded + // budget first; a credential that stays rejected still reaches a human, just later. + if (e instanceof QwpAuthFailedException + && clientFactory.hasDynamicCredential() + && ++dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS) { + 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; + LOG.warn("drainer slot {} attempt {}/{}: the rotating credential was rejected ({}); " + + "retrying with a freshly pulled token after backoff", + slotPath, dynamicCredentialAuthAttempts, + DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS, 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 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 fab637623..00e1c52df 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 @@ -3578,6 +3578,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 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..7fe1a06d9 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,7 @@ 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.cutlass.qwp.client.QwpDurableAckMismatchException; import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer; @@ -54,6 +55,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; @@ -274,6 +276,83 @@ public void testTerminalUpgradeMarksFailedImmediately() throws Exception { }); } + @Test + 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 + public void testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted() throws Exception { + assertMemoryLeak(() -> { + // The settle budget must be BOUNDED, not "retry forever". A credential that stays rejected is not + // healing, and pinning the slot plus a drainer-pool worker indefinitely would starve every other + // orphan slot while telling nobody. Once the budget is spent the drainer quarantines exactly as + // it always did, so an operator still gets the .failed sentinel and the DATA_LOSS report. + ScriptedFactory factory = ScriptedFactory + .alwaysFailing(() -> 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(); + + assertNull(out); + assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome()); + assertEquals("the budget must cap the retries", + 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 + 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 public void testReturnsClientOnSuccessFirstAttempt() throws Exception { assertMemoryLeak(() -> { @@ -1032,6 +1111,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 +1139,11 @@ int attempts() { return calls.get(); } + @Override + public boolean hasDynamicCredential() { + return dynamicCredential; + } + @Override public WebSocketClient reconnect() throws Exception { int n = calls.incrementAndGet(); @@ -1073,6 +1160,11 @@ public WebSocketClient reconnect() throws Exception { WebSocketClient successSentinel() { return successSentinel; } + + ScriptedFactory withDynamicCredential() { + this.dynamicCredential = true; + return this; + } } /** From 0aa7ca057f09c1cbc747e93226777bf43e8dfe83 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 10:22:38 +0100 Subject: [PATCH 095/192] Keep a persisted refresh token with no served kind adopt() rejected a persisted entry whenever its SERVED token was unusable, and threw away the refresh token beside it. Persistence exists to preserve that refresh token, so the process re-ran the interactive device flow where one silent refresh would have done - a hard failure, not a degraded one, for the headless getToken() consumer the feature is built for. The entry shape is legitimate and reachable. Under groupsInToken=false a grant returning 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. A cross-language peer can produce the same file. The two failure shapes needed telling apart, because the safe answer to each is the opposite of the other: absent (null) a legitimate shape, no evidence of anything. Keep the refresh token, leave the cache empty and expired, and let the refresh path do the rest. present but unusable whitespace-only, or carrying a control or non-ASCII char: positive evidence something else wrote the file. Reject the WHOLE entry, refresh token included. That second arm is deliberate and unchanged. Adopting the refresh token of a file known to be tampered with would let whoever can write the store swap in their own and have this client silently sign in as them. The six tamper tests in OidcDeviceAuthPersistenceTest pin exactly that, and an earlier draft of this change that folded the two arms together turned all six red. signIn() needed the second half of the fix. It gated its silent refresh on cachedToken != null, so even with adopt() corrected a restored entry with no served kind still went straight to the device flow. It now attempts the refresh whenever a refresh token is available, the same rule getToken() already applied. A refresh that does not yield the served kind returns false and falls through to the flow below, so this costs at most one wasted request and cannot loop. The regression test restores an entry carrying only an id token and a refresh token, then asserts signIn() spends the refresh and never reaches the device endpoint. Without the production change it fails with "a usable persisted refresh token must not force the device flow expected:<0> but was:<1>". Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 63 ++++++++++++++----- .../auth/OidcDeviceAuthPersistenceTest.java | 34 ++++++++++ 2 files changed, 80 insertions(+), 17 deletions(-) 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 index 781ba5b77..bd485390e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -556,16 +556,21 @@ public String signIn() { 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 re-run the flow rather than report the unusable grant as - // valid and have selectToken() throw on this and every later call + // 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) { - if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { - return cachedToken; - } - if (refreshToken != null && tryRefreshCoordinated()) { - return selectToken(); - } + if (cachedToken != null && System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { + return cachedToken; + } + // 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. + if (refreshToken != null && tryRefreshCoordinated()) { + return selectToken(); } // 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 @@ -1111,15 +1116,40 @@ private boolean adopt(PersistedToken token) { if (token == null) { return false; } - // the file is attacker-writable, so treat the served token (the one getToken() puts verbatim into an - // Authorization header or a PG-wire password) as untrusted: reject a control/non-ASCII char - and the - // whole entry - rather than route a tampered credential onto the wire. A null, empty OR blank - // (whitespace-only) served token is unusable: it passes hasOnlyTokenChars vacuously (space is 0x20) - // but would be served as a blank "Bearer " header that only draws a 401 - and the sender's own - // HttpTokenProvider.validateToken (Chars.isBlank) rejects it downstream anyway - so reject it here on - // the same isBlank contract and fall through to a refresh or sign-in rather than wedge on it. + // 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 + } + accessToken = null; + idToken = null; + refreshToken = fileRefreshToken; + expiresAtMillis = 0; + tokenTtlMillis = 0; + lastPersistedRefreshToken = fileRefreshToken; + return true; + } if (Chars.isBlank(servedToken) || !hasOnlyTokenChars(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), or + // carrying a control or non-ASCII character. 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. return false; } accessToken = token.getAccessToken(); @@ -1129,7 +1159,6 @@ private boolean adopt(PersistedToken token) { // 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. - String fileRefreshToken = token.getRefreshToken(); if (fileRefreshToken != null) { refreshToken = fileRefreshToken; } 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 index 98350fba0..5be14d74f 100644 --- 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 @@ -835,6 +835,40 @@ public void testTamperedServedTokenWithNonAsciiRejectedOnLoad() throws Exception }); } + @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(() -> { From fde8db534858b61b7d578ba13ef4eef1bbaf70ff Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 10:23:03 +0100 Subject: [PATCH 096/192] Harden the token store lock and on-disk format Five fixes to the cross-process lock protocol and the on-disk format, all from the same level-3 review. design/oidc-token-persistence.md is updated to match, since the Python client mirrors it. acquireLock deleted the lock by bare path whenever the exclusive create threw. Its justification - "the create was exclusive, so the file is ours" - holds for one of the failures that arm catches and not the others: from the second loop iteration onward a PEER's live lock sits at that path, and fd exhaustion, EACCES, EROFS, ENOSPC and a Windows sharing violation all arrive as a plain IOException rather than FileAlreadyExistsException. Deleting a peer's live lock admits a second holder, which is the double-POST of one rotating refresh token the lock exists to prevent. It no longer deletes: a lock we really did leave half-created is empty, and stealIfStale reclaims an empty lock on the short grace anyway. stealIfStale restored a wrongly-captured lock with Files.move without REPLACE_EXISTING, and its comment claimed FileAlreadyExistsException would leave a third party's lock intact. It would not. That call stats the target and then renames, and rename(2) replaces silently, so a third party claiming the freed path between the two steps had its live lock destroyed by the very call meant to spare it. The restore now uses link(2) (Files.createLink), which fails outright when the target exists and preserves the peer's exact bytes; a filesystem without hard links falls back to the move. The multi-actor residual that remains is documented rather than implied away. stealIfStale also read a FAILED re-read of the capture as confirmation that the lock was stale, because "the read threw" and "the lock is empty" both left after == null. The steal then completed on the strength of an IO error, the opposite of what its own catch block said it did. afterReadOk separates the two. The empty-lock grace was clamped with Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS, lockStaleMillis). That 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 contract says a client MUST NOT shorten it - and any store built with a sub-5s staleness window silently did. A short window is a statement about abandoned STAMPED locks; the create-to-stamp gap is the same few microseconds however the store is configured. clear() deleted the token file but never the identity's write temps. A crash between createTempFile and the atomic rename orphans a file holding the full entry - refresh token included - in plaintext, and the only sweep runs from save(), bounded by the staleness window. A caller that cleared and never signed in again therefore left a live refresh token on disk indefinitely, contradicting what clear() promises. It now sweeps at any age through sweepTempFiles; a temp a concurrent save is mid-rename on is a benign loser. parseLongOrZero accepted more than the format allows. Numbers.parseLong takes an 'L' suffix and '_' thousands separators, so "1L" parsed as schema version 1 here and as nothing at all in every other language client - a file only this client can read, which is the divergence a frozen format exists to prevent. It screens for a plain JSON integer first. Three regression tests. The acquireLock and stealIfStale fixes have none: both need triggers I could not produce portably - a non-FileAlreadyExists IOException from an exclusive create while a peer's lock occupies the path (ensureDirectory re-chmods the directory on every call, undoing the read-only-directory trick), and a three-actor filesystem race. Both are strict-safety changes: one removes a destructive operation, the other replaces a non-atomic primitive with one that cannot clobber. Without their production changes the three tests fail with "clear must also reclaim an orphaned write temp holding the refresh token", "a 1s-old empty lock is inside the 5s grace and must not be stolen", and "expected null, but was:". The doc update also carries the storeLoadAttempted and adopt() snippets for the two commits before this one, which those commits left stale. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 136 ++++++++++++++---- .../test/cutlass/auth/FileTokenStoreTest.java | 75 ++++++++++ design/oidc-token-persistence.md | 52 ++++++- 3 files changed, 227 insertions(+), 36 deletions(-) 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 index 99be44687..bd62b5b1c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -263,6 +263,15 @@ public void clear(TokenStoreKey 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; }); } @@ -481,6 +490,23 @@ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) { } 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) { @@ -774,14 +800,21 @@ private String acquireLock(Path lock) { } Os.sleep(LOCK_POLL_SLICE_MILLIS); } catch (IOException e) { - // the exclusive create may have succeeded and only the nonce write failed, leaving a partial - // lock; best-effort remove it (the create was exclusive, so the file is ours) so it does not - // wedge peers, then degrade to a lock-free refresh - try { - Files.deleteIfExists(lock); - } catch (IOException ignore) { - // a peer's steal moved it, or it is unreadable; the staleness/grace path settles it - } + // 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. + LOG.warn("could not acquire the OIDC token store lock; running this refresh without " + + "cross-process coordination [error={}]", e.getMessage()); return null; } } @@ -849,18 +882,26 @@ private void stealIfStale(Path lock) { if (!isOlderThan(lock, lockStaleMillis)) { return; } - } else if (!isOlderThan(lock, Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS, lockStaleMillis))) { + } 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.) + // 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"); @@ -872,33 +913,59 @@ private void stealIfStale(Path 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 unreadable/empty junk), not a live - // lock a peer recreated in the gap - final boolean confirmedStale = before == null ? after == null : Arrays.equals(before, after); + // 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: put it back rather than steal it. Use a plain - // move (not ATOMIC_MOVE, which maps to rename(2) and would replace the target): if a third party - // claimed the now-free path during our capture window, FileAlreadyExistsException leaves their lock - // intact and we drop our captured copy rather than clobber it. That drop loses the recreating peer's - // lock file while it still believes it holds the lock, so for that one refresh two holders can run - // concurrently - the inherent residual of stealing with a lock file: a filesystem has no atomic - // "delete/rename only if the content is still X", so the capture-verify shrinks the window to this - // multi-actor race (our steal, a peer recreating, AND a third party claiming the freed path, all - // overlapping) but cannot close it. Best-effort by design: it degrades to one extra refresh - a - // re-prompt on a rotating-refresh-token IdP - never a torn or forged credential (Layer 1 still holds). + // 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.move(captured, lock); - } catch (IOException e) { + 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); + } } } @@ -908,6 +975,13 @@ private void sweepStaleTempFiles(String hashPrefix) { // 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) { @@ -922,7 +996,7 @@ private void sweepStaleTempFiles(String hashPrefix) { continue; } try { - if (now - Files.getLastModifiedTime(tmp).toMillis() > lockStaleMillis) { + if (now - Files.getLastModifiedTime(tmp).toMillis() >= minAgeMillis) { Files.deleteIfExists(tmp); } } catch (IOException ignore) { 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 index 8e02d6c8d..b31bc30a4 100644 --- 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 @@ -313,6 +313,30 @@ public void testControlCharactersRoundTrip() throws Exception { }); } + @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(() -> { @@ -389,6 +413,34 @@ public void testEmptyLockStolenAfterGraceWithinStaleWindow() throws Exception { }); } + @Test + public void testEmptyLockGraceIsNotShortenedByASmallStaleWindow() throws Exception { + assertMemoryLeak(() -> { + Path dir = storeDir(); + Files.createDirectories(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")); @@ -858,6 +910,29 @@ public void testNoLeftoverTempFileAfterSave() throws Exception { }); } + @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(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index a6b24ae06..42abb5b1d 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -203,6 +203,15 @@ Convenience: `FileTokenStore.atDefaultLocation()` and `FileTokenStore.at(Path di legitimately re-persist afterwards. It always forces a fresh sign-in for the calling process, which resets its in-memory token state regardless. + `clear()` MUST also delete the identity's **write temps** (`*.tmp`, excluding `.lock.` + captures — see *Temp-file hygiene* below), at **any age**, not just past the staleness window a + `save()`-time sweep uses. A crash between the temp create and the atomic rename orphans a file + holding the full entry — refresh token included — in plaintext, and a caller that clears and then + never signs in again would otherwise leave that credential on disk indefinitely, which + contradicts what `clear()` promises. A temp a concurrent writer is mid-rename on is a benign + loser: its rename fails, persistence is best-effort, and the caller is discarding the credential + anyway. + ## Integration into `OidcDeviceAuth` All four touch points sit under the existing `ReentrantLock`, so persistence I/O is @@ -212,11 +221,24 @@ already serialised with sign-in/refresh/clear and needs no new locking. guarded by a `boolean storeLoadAttempted` flag: ```java if (tokenStore != null && !storeLoadAttempted) { - storeLoadAttempted = true; // set first: a bad file is not retried every call - PersistedToken t = tokenStore.load(storeKey); + // Latch only AFTER a read that COMPLETED. A missing, corrupt or foreign-identity file + // yields null without throwing, so that answer is definitive and is not re-read. A read + // that THREW is not an answer: latching there would disable persistence for the life of + // the instance on one transient fault, sending a headless getToken() consumer back to an + // interactive flow it cannot run. + PersistedToken t = tokenStore.load(storeKey); // a throw here leaves the flag unset + storeLoadAttempted = true; if (t != null) { - // validate the SERVED token kind exactly as a wire token (reuse validateTokenChars); - // ignore the file on any failure rather than throw + // Tell an ABSENT served kind apart from a CORRUPT one; the safe answer differs. + // absent (null) -> legitimate: a grant that returned only the other kind persists + // this shape. Keep the refresh token, leave the cache empty and + // expired, and let the refresh path do the rest. Discarding it + // would re-prompt a human where one silent refresh would do. + // present but unusable (blank, or a control/non-ASCII char) -> positive evidence + // something else wrote this file. Reject the WHOLE entry, + // refresh token included: adopting the refresh token of a + // tampered file would let whoever can write the store swap in + // their own and have the client silently sign in as them. accessToken = t.getAccessToken(); idToken = t.getIdToken(); refreshToken = t.getRefreshToken(); @@ -375,6 +397,14 @@ serving — but it defeats *sharing*, leaving each client to re-prompt). The document MUST be a single flat JSON object. A reader rejects any other shape - an array anywhere (for example a top-level `[ {…} ]` wrapper) or a non-object root - rather than extract fields from a malformed structure. The Python client MUST do the same. + + The numeric fields (`v`, `expires_at_millis`, `token_ttl_millis`) are **plain JSON integers**: + an optional leading `-` followed by bare digits. A reader MUST NOT accept its own language's + numeric extensions here — QuestDB's `Numbers.parseLong` would otherwise take `1_000` and `5L`, + and Python's `int()` takes `1_000` and surrounding whitespace. A value only one implementation + can parse is a file only that implementation can read, which is exactly the divergence this + frozen format exists to prevent; treat anything outside the plain-integer grammar as unusable + and fall back as for any other bad field. - **Write protocol (atomicity):** write a sibling temp file created with 0600, flush, then **atomically rename** over the target — Java `Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING)`, Python `os.replace(tmp, target)`. Both are `rename(2)` on POSIX @@ -449,7 +479,19 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i - **Temp-file hygiene must not touch steal captures.** A client that sweeps its own stale `*.tmp` files MUST skip any name containing `.lock.`: the capture-then-verify steal below renames the lock to `.lock..tmp` while it decides, and deleting another client's - capture breaks its steal. + capture breaks its steal. (The exclusion applies to `clear()`'s any-age sweep too.) +- **Putting a captured lock back is not a rename.** When capture-then-verify decides the lock it + grabbed is *live* after all — a peer recreated it in the gap — the lock must be restored, and a + plain "rename back if the target is free" is the wrong primitive: Java `Files.move` without + `REPLACE_EXISTING` and Python `os.rename` both stat the target and then rename, so a third party + claiming the freed path between those two steps has its live lock silently destroyed by the very + call meant to leave it alone. Restore with a primitive that fails when the target exists and + cannot replace it — `link(2)` (Java `Files.createLink`, Python `os.link`) followed by unlinking + the capture. A filesystem without hard links may fall back to the rename, accepting that window. + A residual remains either way: if a third party did claim the path, the recreating peer's lock + file is gone while that peer still believes it holds the lock, so two holders can run for that + one refresh. No filesystem offers an atomic "rename only if the content is still X", so the + capture-verify narrows this window without closing it. - **Release verifies ownership.** A holder releases by re-reading the lock and deleting it **only when it still carries that holder's own owner stamp**, never by bare path. Should a hold ever outrun the staleness window and be stolen and recreated by a peer, the From 4b52bf62fdfdd20742d6f21424519070ae643165 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 13:49:43 +0100 Subject: [PATCH 097/192] Ride out a rotating 401 that lands mid-drain The rotating-401 ride-out for the orphan drainer lived only in connectWithDurableAckRetry(), which runs on the initial connect and the durable-ack capability-gap recycle. A wire drop DURING a drain is reconnected by the ORPHAN CursorWebSocketSendLoop's own connectLoop, where endpointPolicyFailureIsTerminal() is unconditionally true for an orphan. So a mid-drain 401 latched a fatal SECURITY_ERROR and BackgroundDrainer.run() quarantined the slot on the first rejection, permanently abandoning replayable data on the exact self-healing window (a revocation landing mid-flight, the IdP rotating signing keys, clock skew) the ride-out exists to survive. connectLoop now publishes an authTerminal marker for a rotating- credential 401 on an orphan loop, mirroring how a capability gap publishes capabilityGapTerminal: the loop still latches so run() regains control, but run() routes the marker into connectWithDurableAckRetry()'s bounded ride-out instead of quarantining. A constant credential and a non-421 upgrade reject stay fatal and quarantine on the first sweep, unchanged. Foreground senders never set the marker (it gates on reconnectPolicy == ORPHAN), so an initializing 401 still reaches the caller. Each mid-drain recycle re-enters the six-attempt ride-out fresh, but only after a successful connect and drain progress, so a persistently revoked credential still quarantines within six consecutive failures rather than riding out forever. BackgroundDrainerMidDrainAuthRejectTest drives the full run() over a real socket: a rotation that heals within the budget drains to success (fails without this change with "expected: but was:"), a persistent 401 quarantines after 2 + 6 attempts, and a constant credential quarantines on the first sweep. Co-Authored-By: Claude Opus 4.8 --- .../client/sf/cursor/BackgroundDrainer.java | 36 +- .../sf/cursor/CursorWebSocketSendLoop.java | 63 ++- ...ckgroundDrainerMidDrainAuthRejectTest.java | 386 ++++++++++++++++++ 3 files changed, 469 insertions(+), 16 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java 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 c14ef0105..946a98c22 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 @@ -858,17 +858,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) { 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 00e1c52df..6e6c697b3 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 @@ -438,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 @@ -1190,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 @@ -1797,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( 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..bf089287b --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java @@ -0,0 +1,386 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +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 + * ({@link BackgroundDrainer#DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS}) + * 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 = 60_000L; + private static final int SEEDED_FRAMES = 5; + private static final long SEGMENT_SIZE_BYTES = 16384L; + private static final long SF_MAX_TOTAL_BYTES = 1L << 20; + + private String slotPath; + + @Before + public void setUp() { + slotPath = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-mid-drain-auth-" + System.nanoTime()).toString(); + assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT)); + } + + @After + public void tearDown() { + rmDirRec(slotPath); + } + + @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()); + drainer.setErrorSink(captured::add); + + runToCompletion(drainer); + + assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome()); + assertTrue("a constant-credential 401 must quarantine the slot", + Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + assertEquals("a constant credential must not consume the rotating-401 ride-out", + 2, factory.attempts()); + assertEquals("exactly one abandonment report: " + captured, 1, captured.size()); + assertEquals(SenderError.Category.DATA_LOSS, captured.get(0).getCategory()); + } + }); + } + + @Test + public void testMidDrainPersistentRotating401ExhaustsRideOutThenQuarantines() 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)); + // The rotation never heals: every sweep after the drop is a 401. + ScriptedWireFactory factory = new ScriptedWireFactory( + server.getPort(), 2, Integer.MAX_VALUE, /* dynamicCredential */ true); + BackgroundDrainer drainer = newDrainer(factory); + List captured = Collections.synchronizedList(new ArrayList()); + drainer.setErrorSink(captured::add); + + runToCompletion(drainer); + + assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome()); + assertTrue("a persistent rotating 401 must quarantine after the ride-out", + Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + // 1 healthy connect + 1 loop reconnect sweep (latches the loop's + // authTerminal) + the full ride-out of re-entered sweeps. + assertEquals(2 + BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS, + factory.attempts()); + assertEquals("exactly one abandonment report: " + captured, 1, captured.size()); + assertEquals(SenderError.Category.DATA_LOSS, captured.get(0).getCategory()); + } + }); + } + + @Test + public void testMidDrainRotating401RidesOutThenDrains() 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)); + // Call 1: healthy connect (drain starts; server durably acks one + // frame, then drops the wire). Calls 2-4: the reconnect sweep is + // refused with a 401. Call 5+: the rotation healed; the freshly + // pulled token is accepted and the drain completes. + ScriptedWireFactory factory = new ScriptedWireFactory( + server.getPort(), 2, 4, /* dynamicCredential */ true); + BackgroundDrainer drainer = newDrainer(factory); + List captured = Collections.synchronizedList(new ArrayList()); + drainer.setErrorSink(captured::add); + + runToCompletion(drainer); + + // Without the mid-drain ride-out the first 401 (call 2) latches a + // fatal terminal and the drainer quarantines: outcome FAILED, + // attempts == 2, a .failed sentinel. The fix routes it into the + // ride-out instead, so the drain survives the rotation. + assertEquals("a rotating 401 that heals within the ride-out must not quarantine", + BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome()); + assertFalse("no .failed sentinel after a successful drain", + Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + assertTrue("expected the drainer to ride out the 401s, attempts=" + factory.attempts(), + factory.attempts() >= 5); + assertTrue("a healed rotation must report no data loss: " + captured, captured.isEmpty()); + } + }); + } + + 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 rmDirRec(String dir) { + if (dir == null || !Files.exists(dir)) return; + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + String child = dir + "/" + name; + if (!Files.remove(child)) rmDirRec(child); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(dir); + } + + private static void runToCompletion(BackgroundDrainer drainer) throws InterruptedException { + Thread t = new Thread(drainer, "test-mid-drain-auth-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); + } + } + } + + /** + * Server-side script. Connection #1 durably acks exactly one frame, then + * closes the socket — a deterministic mid-drain wire drop. Every later + * connection acks all traffic, so a reconnected loop drains to completion. + * Keyed per {@code ClientHandler} identity; a dead connection's late + * buffered frames are ignored rather than acked with a stale counter. + */ + private static final class DropFirstHandler implements TestWebSocketServer.WebSocketServerHandler { + private static final String TABLE = "trades"; + 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 + // on its next connection. + } + } + + 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(); + } + } + + /** + * Per-call-index scripted factory over a real wire. Call indexes inside + * {@code [throwFrom, throwTo]} (1-based, inclusive) throw a + * {@link QwpAuthFailedException} (401); every other call returns a live + * upgraded client against the test server. {@link #hasDynamicCredential()} + * reports whether the credential rotates — the signal the orphan drainer's + * terminal policy reads. + */ + private static final class ScriptedWireFactory implements CursorWebSocketSendLoop.ReconnectFactory { + private final AtomicInteger calls = new AtomicInteger(); + private final boolean dynamicCredential; + private final int port; + private final int throwFrom; + private final int throwTo; + + ScriptedWireFactory(int port, int throwFrom, int throwTo, boolean dynamicCredential) { + this.port = port; + this.throwFrom = throwFrom; + this.throwTo = throwTo; + this.dynamicCredential = dynamicCredential; + } + + int attempts() { + return calls.get(); + } + + @Override + public boolean hasDynamicCredential() { + return dynamicCredential; + } + + @Override + public WebSocketClient reconnect() throws Exception { + int n = calls.incrementAndGet(); + if (n >= throwFrom && n <= throwTo) { + throw new QwpAuthFailedException(401, "localhost", port); + } + 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; + } + } +} From fa13d4db251b597bb3ee5fad136fd7299e795132 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 14:19:43 +0100 Subject: [PATCH 098/192] Surface mock handler errors; leak-check QWP tests Two test-quality fixes from the level-3 review of this branch. MockOidcServer ran each Handler on a daemon connection thread and caught only SocketException/IOException, so an assertion inside a handler escaped as an uncaught throwable: the client saw only a transport drop, which it might tolerate as a silent false pass or retry into an opaque @Test timeout. handleConnection now captures the first handler throwable, drops the connection exactly as before, and close() rethrows it on the test thread after teardown, so a broken handler assertion fails its test with the real cause (as the primary failure, or suppressed on the primary when the client failed first). QwpQueryClientTokenProviderTest constructed a QwpQueryClient, which mallocs native scratch in its constructor, in all eleven tests without assertMemoryLeak, unlike the sibling suite. Each test now runs under assertMemoryLeak, proving that scratch is freed on close, including on the connect, failover and error paths. Both changes are test-only. OidcDeviceAuthTest (120) and OidcDeviceAuthPersistenceTest (31) still pass under the mock change, and a deliberately broken handler assertion now surfaces as a suppressed AssertionError rather than being swallowed. Co-Authored-By: Claude Opus 4.8 --- .../test/cutlass/auth/MockOidcServer.java | 34 +- .../QwpQueryClientTokenProviderTest.java | 382 ++++++++++-------- 2 files changed, 238 insertions(+), 178 deletions(-) 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 index 66e24c42a..81b222a36 100644 --- 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 @@ -39,6 +39,7 @@ 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, @@ -52,6 +53,10 @@ public class MockOidcServer implements Closeable { 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; @@ -134,6 +139,21 @@ public void close() throws IOException { 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) { @@ -351,7 +371,19 @@ private void handleConnection(Socket socket) { Request request; while ((request = readRequest(in)) != null) { requestAuthHeaders.add(request.authorization); - MockResponse response = handler.handle(request.method, request.path, request.body); + 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 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 index d2ca25d70..22a3b28b3 100644 --- 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 @@ -49,6 +49,8 @@ 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), @@ -60,6 +62,10 @@ * 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 { @@ -78,234 +84,256 @@ public void onError(byte status, String message) { }; @Test - public void testProviderConflictsWithBasicAuth() { - 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 + 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() { - 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 + 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() { - // 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")); + 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() { - try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)) { - try { - c.withBearerTokenProvider(null); - Assert.fail("a null provider must be rejected"); - } catch (IllegalArgumentException expected) { - // expected + 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() { - 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()); - } + 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() { - try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) - .withBearerTokenProvider(() -> "abc123")) { - Assert.assertEquals("Bearer abc123", c.getAuthorizationHeaderForTest()); - } + 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 { - // 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)); + 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); + 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(); + // 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); + // 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); + 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(); } - } finally { - a.close(); - b.close(); - } + }); } @Test(timeout = 15_000) public void testProviderTokenSentOnRealUpgrade() throws Exception { - // 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. - List authHeaders = Collections.synchronizedList(new ArrayList<>()); - ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); - int port = listener.getLocalPort(); - byte[] respBytes = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - Thread serverThread = new Thread(() -> { - while (!listener.isClosed()) { - try { - Socket s = listener.accept(); - Thread handler = new Thread(() -> { - try (Socket sock = s) { - byte[] buf = new byte[8192]; - int n = sock.getInputStream().read(buf); - if (n < 0) { - return; - } - String request = new String(buf, 0, n, StandardCharsets.US_ASCII); - for (String line : request.split("\r\n")) { - if (line.regionMatches(true, 0, "Authorization:", 0, "Authorization:".length())) { - authHeaders.add(line.substring("Authorization:".length()).trim()); + 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. + List authHeaders = Collections.synchronizedList(new ArrayList<>()); + ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + int port = listener.getLocalPort(); + byte[] respBytes = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + Thread serverThread = new Thread(() -> { + while (!listener.isClosed()) { + try { + Socket s = listener.accept(); + Thread handler = new Thread(() -> { + try (Socket sock = s) { + byte[] buf = new byte[8192]; + int n = sock.getInputStream().read(buf); + if (n < 0) { + return; + } + String request = new String(buf, 0, n, StandardCharsets.US_ASCII); + for (String line : request.split("\r\n")) { + if (line.regionMatches(true, 0, "Authorization:", 0, "Authorization:".length())) { + authHeaders.add(line.substring("Authorization:".length()).trim()); + } } + OutputStream os = sock.getOutputStream(); + os.write(respBytes); + os.flush(); + } catch (Exception ignored) { } - OutputStream os = sock.getOutputStream(); - os.write(respBytes); - os.flush(); - } catch (Exception ignored) { - } - }, "qwp-token-upgrade-handler"); - handler.setDaemon(true); - handler.start(); - } catch (Exception ignored) { - return; + }, "qwp-token-upgrade-handler"); + handler.setDaemon(true); + handler.start(); + } catch (Exception ignored) { + return; + } } - } - }, "qwp-token-upgrade-server"); - serverThread.setDaemon(true); - serverThread.start(); + }, "qwp-token-upgrade-server"); + serverThread.setDaemon(true); + serverThread.start(); - try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=127.0.0.1:" + 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 above + try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=127.0.0.1:" + 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 above + } + } finally { + listener.close(); + serverThread.join(500); } - } finally { - listener.close(); - serverThread.join(500); - } - Assert.assertEquals("the provider's token must reach the real upgrade request", 1, authHeaders.size()); - Assert.assertEquals("Bearer tok-0", authHeaders.get(0)); + 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() { - 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")); + 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() { - 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 + 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 { - // 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")); + 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) { From 1a0bad96f331902bd66b79b3f2fd319ed77da7a2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 6 Aug 2026 16:48:05 +0100 Subject: [PATCH 099/192] Harden rotating auth retry and provider failures Require both the rotating-credential rejection attempt threshold and the configured reconnect dwell before quarantining an orphan slot. Normalize OIDC token-provider runtime failures to LineSenderException in the ILP and QWP query paths while preserving their causes and retry state. --- .../line/http/AbstractLineHttpSender.java | 13 +++- .../cutlass/qwp/client/QwpQueryClient.java | 16 +++- .../client/sf/cursor/BackgroundDrainer.java | 74 ++++++++++++------- .../test/cutlass/auth/OidcDeviceAuthTest.java | 5 +- .../QwpQueryClientTokenProviderTest.java | 21 ++++++ .../BackgroundDrainerDurableAckRetryTest.java | 46 ++++++++++-- ...ckgroundDrainerMidDrainAuthRejectTest.java | 18 ++--- 7 files changed, 147 insertions(+), 46 deletions(-) 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 a61c5219e..4e5208dfa 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 @@ -840,7 +840,18 @@ private void stampTokenIfPending() { // 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 token = httpTokenProvider.getToken(); + CharSequence token; + try { + token = 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); + } HttpTokenProvider.validateToken(token); request.authToken(token); request.withContent(); 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 007d79457..4c2ac548e 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 @@ -30,6 +30,7 @@ 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; @@ -1909,9 +1910,20 @@ private String resolveAuthorizationHeader() { // 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) - // propagates out of connect()/reconnect with its own message, matching the QWP ingress sender. + // fails connect()/reconnect as a LineSenderException, preserving the provider failure as its cause. if (tokenProvider != null) { - CharSequence token = tokenProvider.getToken(); + CharSequence token; + try { + token = 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); + } HttpTokenProvider.validateToken(token); return "Bearer " + token; } 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 946a98c22..359872d65 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 @@ -93,18 +93,19 @@ public final class BackgroundDrainer implements Runnable { */ public static final int DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS = 16; /** - * How many consecutive {@code 401}/{@code 403} rejections an orphan drainer rides out before it - * quarantines the slot, when - and only when - the credential is a ROTATING one + * 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. *

    - * Small on purpose. Its job is to outlast a window that heals on its own - a revocation landing - * mid-flight, an identity provider rotating signing keys, a token that expires during the settle so - * the next pull refreshes it - not to wait out a genuinely revoked grant. A credential that stays - * rejected must still reach a human rather than pin the slot and a drainer-pool worker forever, so - * the budget is attempt-counted and deliberately short. Note it cannot repair a PERSISTENT clock - * skew: the provider keeps serving the same cached token, so those sweeps simply exhaust the budget - * and quarantine, which is the right end state for a condition that is not healing. + * The attempt threshold is necessary but not sufficient: the rejection must also persist for at + * least {@code reconnectMaxDurationMillis}, measured from the first rejection. 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; private static final Logger LOG = LoggerFactory.getLogger(BackgroundDrainer.class); @@ -296,8 +297,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 @@ -317,14 +319,18 @@ public WebSocketClient connectWithDurableAckRetry() { // cluster leaves the capability-gap state, later gaps must establish a // fresh consecutive run before quarantine is permitted. int capabilityGapAttempts = 0; - // Consecutive 401/403 sweeps ridden out so far, counted only for a ROTATING credential (see + // 401/403 sweeps ridden out so far, counted only for a ROTATING credential (see // DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS). Never reset: unlike the capability-gap episode - // this budget is per-drain, not per-episode, so a credential that alternates rejected/unreachable - // cannot refill it indefinitely and stall the quarantine that an operator needs to see. + // this threshold is per-drain, not per-episode, so a credential that alternates + // rejected/unreachable cannot refill it indefinitely and stall the quarantine that an operator + // needs to see. int dynamicCredentialAuthAttempts = 0; + // The rotating-auth wall-clock floor is anchored at the first 401/403 and, like the attempt + // threshold, never resets during this drain. A zero value means no rejection has been observed. + long firstDynamicCredentialAuthFailureNanos = 0L; // 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. @@ -338,7 +344,7 @@ 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); // Observability-only counter for the transient all-replica window; // never consulted for escalation (Invariant B). @@ -366,11 +372,24 @@ public WebSocketClient connectWithDurableAckRetry() { // 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 in seconds. Ride out a small bounded - // budget first; a credential that stays rejected still reaches a human, just later. - if (e instanceof QwpAuthFailedException - && clientFactory.hasDynamicCredential() - && ++dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS) { + // 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; + retryDynamicCredentialAuth = + dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS + || dynamicCredentialAuthElapsedNanos < reconnectBudgetNanos; + } + 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 @@ -378,10 +397,13 @@ public WebSocketClient connectWithDurableAckRetry() { capabilityGapAttempts = 0; capabilityGapElapsedNanos = 0L; lastCapabilityGapNanos = 0L; - LOG.warn("drainer slot {} attempt {}/{}: the rotating credential was rejected ({}); " - + "retrying with a freshly pulled token after backoff", + 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, e.getMessage()); + DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS, + dynamicCredentialAuthElapsedNanos / 1_000_000L, + reconnectMaxDurationMillis, e.getMessage()); // fall through to the shared capped-backoff block } else { String msg = e.getMessage(); @@ -443,7 +465,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): {}", @@ -541,7 +563,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; 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 index 04db28a74..7af85be7d 100644 --- 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 @@ -32,6 +32,7 @@ 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.LineSenderException; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Os; import io.questdb.client.std.Unsafe; @@ -1882,8 +1883,10 @@ public void testHttpSenderProviderFailureAfterFlushDoesNotCorruptSender() throws try { sender.table("t").doubleColumn("x", 2.0).atNow(); Assert.fail("expected the failing provider pull to surface on the next row"); - } catch (OidcAuthException e) { + } 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 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 index 22a3b28b3..d7fce50b2 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -83,6 +84,26 @@ public void onError(byte status, String message) { } }; + @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(() -> { 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 7fe1a06d9..b58f57d2d 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 @@ -303,29 +303,61 @@ public void testFixedCredentialAuthRejectionStillQuarantinesImmediately() throws @Test public void testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted() throws Exception { assertMemoryLeak(() -> { - // The settle budget must be BOUNDED, not "retry forever". A credential that stays rejected is not - // healing, and pinning the slot plus a drainer-pool worker indefinitely would starve every other - // orphan slot while telling nobody. Once the budget is spent the drainer quarantines exactly as - // it always did, so an operator still gets the .failed sentinel and the DATA_LOSS report. + // 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(); - BackgroundDrainer drainer = newDrainer(factory); + long authDwellFloorMillis = 25L; + 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()); - assertEquals("the budget must cap the retries", - BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS, factory.attempts()); + 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 + 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 public void testRotatingCredentialAuthRejectionRidesOutBoundedBudget() throws Exception { assertMemoryLeak(() -> { 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 index bf089287b..6bcf92edb 100644 --- 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 @@ -61,10 +61,10 @@ * 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 - * ({@link BackgroundDrainer#DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS}) - * 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 + * 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 @@ -88,7 +88,7 @@ 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 = 60_000L; + private static final long RECONNECT_MAX_DURATION_MILLIS = 25L; private static final int SEEDED_FRAMES = 5; private static final long SEGMENT_SIZE_BYTES = 16384L; private static final long SF_MAX_TOTAL_BYTES = 1L << 20; @@ -156,10 +156,10 @@ public void testMidDrainPersistentRotating401ExhaustsRideOutThenQuarantines() th assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome()); assertTrue("a persistent rotating 401 must quarantine after the ride-out", Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); - // 1 healthy connect + 1 loop reconnect sweep (latches the loop's - // authTerminal) + the full ride-out of re-entered sweeps. - assertEquals(2 + BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS, - factory.attempts()); + // 1 healthy connect + 1 loop reconnect sweep (latches the loop's authTerminal) + enough + // re-entered sweeps to satisfy both the attempt threshold and the wall-clock dwell floor. + assertTrue("the drainer must reach the rotating-auth attempt threshold", + factory.attempts() >= 2 + BackgroundDrainer.DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS); assertEquals("exactly one abandonment report: " + captured, 1, captured.size()); assertEquals(SenderError.Category.DATA_LOSS, captured.get(0).getCategory()); } From 2d08b598c8ea47fbdfbfe4c6cd9098d2c89919c0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 18:50:52 +0100 Subject: [PATCH 100/192] feat: support token providers for pooled clients --- README.md | 28 ++--- .../io/questdb/client/HttpTokenProvider.java | 42 +++---- .../main/java/io/questdb/client/QuestDB.java | 31 ++++++ .../io/questdb/client/QuestDBBuilder.java | 35 ++++++ .../questdb/client/impl/QueryClientPool.java | 25 ++++- .../io/questdb/client/impl/QuestDBImpl.java | 33 +++++- .../io/questdb/client/impl/SenderPool.java | 48 +++++++- .../client/test/QuestDBBuilderTest.java | 103 ++++++++++++++++++ .../example/sender/OidcDeviceFlowExample.java | 24 ++-- 9 files changed, 315 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 5604798d8..5a9070c5f 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,7 @@ For QuestDB Enterprise instances secured with OIDC, `OidcDeviceAuth` signs a use On first use it prints a verification URL and a short code, and opens the URL in your default browser when one is available; authorize there (or open the URL on any device, such as your phone), enter the code, and the token is cached in memory and refreshed silently on later calls. ```java +import io.questdb.client.QuestDB; import io.questdb.client.Sender; import io.questdb.client.cutlass.auth.OidcDeviceAuth; @@ -399,23 +400,24 @@ import io.questdb.client.cutlass.auth.OidcDeviceAuth; try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { auth.signIn(); // sign in once: prompts on first use, then caches and refreshes - // Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each - // request, so a long-lived sender keeps working as the token rotates. getToken() refreshes - // silently and never prompts on the flush path. - try (Sender sender = Sender.builder(Sender.Transport.HTTP) - .address("questdb.example.com:9000") - .enableTls() - .httpTokenProvider(auth::getToken) - .build()) { - sender.table("trades") - .symbol("symbol", "ETH-USD") - .doubleColumn("price", 2615.54) - .atNow(); + // The provider is shared by the ingest and query pools. It is queried for + // every initial WebSocket upgrade and reconnect, so both pools follow token + // rotation without putting a credential in the configuration string. + try (QuestDB db = QuestDB.connect( + "wss::addr=questdb.example.com:9000;", + auth::getToken)) { + try (Sender sender = db.borrowSender()) { + sender.table("trades") + .symbol("symbol", "ETH-USD") + .doubleColumn("price", 2615.54) + .atNow(); + } + // db.borrowQuery() uses the same provider for query connections. } } ``` -Prefer `httpTokenProvider(auth::getToken)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. +For a standalone sender, use `httpTokenProvider(auth::getToken)` for the same rotating-token behavior. A fixed `httpToken(token)` or `token=` connect-string value captures the token once, so a client that reconnects after that token expires starts failing authentication. Hand rotating credentials to the provider API, not a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index 2344b8a92..31e3db1a9 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -28,30 +28,34 @@ import io.questdb.client.std.Chars; /** - * Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a - * freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken} - keeps a long-lived sender - * authenticated as the token rotates, without rebuilding it. Over HTTP the sender calls - * {@link #getToken()} as it builds each request; over WebSocket it calls it once per connection - * handshake, on the initial connect and again on every reconnect. + * 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 the sender's flush and reconnect paths: it must return promptly and must - * not block on interactive input. A quick silent token refresh is fine, but it must not start an - * interactive sign-in; 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 OS, - * not by the client timeout - so a black-holed token endpoint can stall a refresh for the OS connect - * timeout (commonly ~2 minutes on Linux). A producer sizing flush backpressure against this call should - * expect that worst case. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the - * connection attempt (WebSocket). + * {@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 OS, not by the client timeout - so a black-holed token endpoint can stall a refresh + * for the OS connect timeout (commonly ~2 minutes on Linux). A producer sizing flush backpressure + * against this call should expect that worst case. 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 sender writes it into an + * Validates a token returned by {@link #getToken()} before the client writes it into an * {@code Authorization: Bearer} header. Rejects a null, empty or blank token, and any token * carrying a control or non-ASCII character (outside {@code 0x20}-{@code 0x7e}): a real bearer * token is printable ASCII, so a stray CR/LF (which would inject into the HTTP request line) or a @@ -76,9 +80,9 @@ static void validateToken(CharSequence token) { } /** - * Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the sender + * 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 sender splices the value verbatim into an {@code Authorization: + * 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)}). * * @return the current HTTP authentication token 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/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/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..b902d7d97 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; @@ -1993,6 +2023,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 +2058,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 +2126,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/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java index 6cf5eb09a..b880449a8 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,19 @@ 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.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 +85,77 @@ 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 + 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 +357,32 @@ private static void assertBuildRejected(String config, String expectedFragment) } } + 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(Set.of(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/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java index 3e691abea..fe0cc414d 100644 --- a/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -1,5 +1,6 @@ package com.example.sender; +import io.questdb.client.QuestDB; import io.questdb.client.Sender; import io.questdb.client.cutlass.auth.OidcDeviceAuth; @@ -28,17 +29,18 @@ public static void main(String[] args) { try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { auth.signIn(); // sign in once (prompts on first use, then caches and refreshes silently) - // 1. Ingest with the QuestDB client over ILP-over-HTTP, presenting the token as a Bearer. - // Pass a provider, not the fixed token, so a long-lived sender follows silent refreshes. - try (Sender sender = Sender.builder(Sender.Transport.HTTP) - .address("questdb.example.com:9000") - .enableTls() - .httpTokenProvider(auth::getToken) - .build()) { - sender.table("trades") - .symbol("symbol", "ETH-USD") - .doubleColumn("price", 2615.54) - .atNow(); + // 1. Use one pooled QWP handle for ingest and queries. The provider is shared by both + // pools and queried again on each reconnect, so long-lived clients follow silent refreshes. + try (QuestDB db = QuestDB.connect( + "wss::addr=questdb.example.com:9000;", + auth::getToken)) { + try (Sender sender = db.borrowSender()) { + sender.table("trades") + .symbol("symbol", "ETH-USD") + .doubleColumn("price", 2615.54) + .atNow(); + } + // db.borrowQuery() uses the same rotating bearer-token provider. } // 2. Query the REST API directly: send the token in the Authorization header. From 9a5eb88eda2e6fc6e7d107c5c92d60c2d8bba12f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 19:06:06 +0100 Subject: [PATCH 101/192] update review-pr skill --- .claude/skills/review-pr/SKILL.md | 729 +++++++++++++++++++++++------- 1 file changed, 573 insertions(+), 156 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index b8ad5679d..998adc8a1 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -1,24 +1,48 @@ --- name: review-pr -description: Review a GitHub pull request against QuestDB coding standards. Performs an adversarial, blocking, mission-critical code review covering correctness, concurrency, performance, resource management, test coverage, test efficacy, test-code quality, and QuestDB conventions, then verifies every finding against source before reporting. -argument-hint: [PR number or URL] [--level=0..3] -allowed-tools: Bash(gh *), Read, Grep, Glob, Agent +description: Review a GitHub pull request or local Git range against QuestDB coding standards +argument-hint: "[PR number or URL | --range=..] [--level=0..3]" +allowed-tools: Bash, Read, Grep, Glob, Agent --- -Review the pull request `$ARGUMENTS`. +# Review a QuestDB pull request + +**Usage:** `/review-pr [PR number or URL | --range=..] [--level=0..3]` + +Review the PR or local range identified by the invocation arguments. When this skill +is run as `/skill:review-pr `, the `` are appended as a `User:` message; +treat that text as `$ARGUMENTS`. Parse exactly one review target: a PR number/URL, +or `--range=..`. The range head may be omitted (`--range=..`) to +review the working tree, including uncommitted changes. If both targets are supplied, +stop and ask which was intended. If neither is supplied, ask for one. + +**Tools this skill uses:** `Bash` for read-only `gh` and Git queries, `Read`, `Grep`, +`Glob`, and fresh-context agents through the Agent tool. Do not edit files or push. ## Review mindset -You are a senior QuestDB engineer performing a blocking code review. QuestDB is mission-critical software deployed on spacecraft — bugs can cause data loss or system failures that cannot be patched after deployment. There is zero tolerance for correctness issues, resource leaks, or undefined behavior. Be critical, thorough, and opinionated. Your job is to catch problems before they ship, not to be nice. +You are a senior QuestDB engineer performing a blocking code review. QuestDB is mission-critical software: bugs can cause data loss or system failures in production deployments that are expensive to patch. Be critical, thorough, and opinionated. Your job is to catch problems that would hurt a user before they ship — not to be nice, and not to demonstrate thoroughness by volume. + +**A review that blocks on everything blocks on nothing.** Every finding costs the author a CI round-trip, and an inflated one costs the whole report its credibility. Reserve blocking severity for defects with a real user consequence, report everything else honestly at the severity it deserves, and approve when the gates pass. "Approve" is a normal, expected outcome of reviewing competent work — not a failure of rigour. - **Assume nothing is correct until you've verified it.** Read surrounding code to understand context — don't just look at the diff in isolation. - **The diff is a hint, not the boundary of the review.** The highest-value bugs almost always live at callsites outside the diff that depend on contracts the diff quietly changed. Treat the diff as the entry point, not the scope. -- **Flag every issue you find**, no matter how small. Do not soften language or hedge. Say "this is wrong" not "this might be an issue". +- **Discovery is not a finding.** Treat every concern — including one produced by several agents — as an untrusted hypothesis until it passes the Step 3b admission gate. Report every *admitted* issue at the severity its evidence earns; omit everything else. A review with zero findings is a successful outcome. +- **Falsify before you explain.** Search for the missing producer, unsupported configuration, omitted caller, retry, guard, downstream offset, and merge-base behavior before building a narrative. Failure to disprove a hypothesis is not evidence for it, and uncertainty is never promoted to severity. +- **Keep the blast radius of the PR small.** This PR should fix what it set out to fix, plus anything this change demonstrably breaks. Pre-existing bugs, residual hardening opportunities whose behavior is unchanged from base, and propositions that only support another candidate are never findings against this PR and never affect its verdict. The one exception is a pre-existing bug that this PR demonstrably moves onto a live path. Small blast radius governs what this PR must *fix*, not what the review is allowed to *know*: a pre-existing bug proved to the same evidence bar leaves as a Step 4 adjacent issue draft rather than being thrown away. - **Do not praise the code.** Skip "looks good", "nice work", "clever approach". Focus entirely on problems and risks. - **Think adversarially.** For each change, ask: what inputs break this? What happens under concurrent access? What if this runs on a 10-billion-row table? What if the column is NULL? What if the partition is empty? +- **Demand optimal algorithms where they matter.** QuestDB is a performance-first database. On data paths, "works + correctly" is not sufficient — a linear scan where a hash lookup exists, two passes where one suffices, or a per-row + allocation on a scan is a blocking defect. Off the data path, apply judgement: a bounded, non-scaling cost during SQL + compilation, DDL, or startup is worth reporting as Moderate, not worth blocking a merge over. Ask "is there a faster + way?" for every loop, traversal, and data-structure choice — then ask "does the user feel the difference?" before + choosing the severity. - **Check what's missing**, not just what's there. Missing tests, missing error handling, missing edge cases, missing documentation for non-obvious behavior. -- **Verify every claim.** If the PR title says "fix", verify the bug actually existed and the fix is correct. If it says "improve performance", look for benchmarks or reason about the algorithmic change — does it actually improve things, or could it regress in other cases? If it says "simplify", verify the new code is actually simpler and doesn't drop behavior. Treat the PR description as an unverified hypothesis, not a statement of fact. -- **Read the full context of changed files** when the diff alone is ambiguous. Use Read/Grep/Glob to inspect the surrounding code, callers, and related tests. +- **Untested changed behavior is a coverage risk, not proof of a defect.** Missing tests alone cannot make a finding Critical. A Critical coverage gap must identify a supported, reachable user/operator population and a credible regression mode with material impact. A named test with a real failure link remains the strongest evidence; when none exists, assess change risk and the least fragile meaningful test rather than blocking by category. Test difficulty never reduces the severity of an actual functional, security, availability, corruption, or data-loss defect. +- **Urgency is neither evidence nor an exemption.** It may inform delivery sequencing only after user impact, regression risk, and stable-test feasibility are established. "Urgent", "simple", and "hard to test" are conclusions to prove, not reasons to skip analysis. +- **Verify every claim.** If the PR title says "fix", verify the bug actually existed and the fix is correct. If it says "improve performance", look for benchmarks or reason about the algorithmic change — does it actually improve things, or could it regress in other cases? Even if the PR doesn't claim to be about performance, evaluate whether the chosen algorithms and data structures are optimal — sub-optimal code that "works" is still a finding. If it says "simplify", verify the new code is actually simpler and doesn't drop behavior. Treat the PR description as an unverified hypothesis, not a statement of fact. +- **Read the full context of changed files** when the diff alone is ambiguous. Use `Read` plus ripgrep (`rg` with Bash) and `fd` to inspect the surrounding code, callers, and related tests. - **Assess reachability before reporting.** For every potential bug, trace the actual callers and inputs. If a problem requires physically impossible conditions (billions of columns, corrupted JNI inputs, values that no caller can produce), it is not a real finding — drop it. Focus on bugs that real workloads can trigger, not theoretical edge @@ -30,52 +54,117 @@ You are a senior QuestDB engineer performing a blocking code review. QuestDB is ## Review level -Parse `$ARGUMENTS` for a level token: `--level=N`, `-lN`, or a bare single digit `0`-`3`. **If no level is given, default to 0.** Strip the level token before feeding the remainder (PR number or URL) to `gh` commands. +Parse `$ARGUMENTS` for a level token: `--level=N`, `-lN`, or a bare single digit `0`-`3`. **If no level is given, default to 0.** Strip the level token and any `--range=` token before feeding the remainder (PR number or URL) to `gh` commands. The level controls how much of the review below actually runs. Lower levels keep the same review *spirit* — adversarial, blocking, no praise — but cut the breadth of the analysis. Higher levels have significantly higher token cost; reserve level 3 for high-stakes PRs (replication, JNI boundary changes, on-disk format, public API, security/ACL). | Level | What runs | |-------|-----------| -| **0 (default)** | Steps 1, 2, 4. Skip Step 2.5. Skip Step 3 — no agent spawn; review the diff inline in the main loop, using Read/Grep on demand to resolve ambiguities. Skip Step 3b — verify each finding inline as you write it. Single-pass review covering correctness, NULL handling, test coverage, and QuestDB standards on the diff itself. When the diff touches test code, also apply the test-efficacy and test-code-quality anti-pattern checks inline (vacuous assertions, reflection overuse, reinvented helpers, javadoc bloat). | -| **1** | Adds Step 2.5a (semantic delta only — skip 2.5b/2.5c/2.5d) plus Step 2.5e when test code is present. In Step 3, launch Agent 1 (correctness), Agent 5 (test coverage), Agent 6 (code quality), and — when the diff touches test code — Agent 11 (test efficacy) and Agent 12 (test-code quality) in parallel. Skip all other agents. Skip Step 3b — verify findings inline as you draft the report. | -| **2** | Full Step 2.5 (including 2.5e when test code is present), but in 2.5b restrict the callsite inventory to `public`/`protected` symbols (skip package-private and `pub(crate)`). In Step 3, launch Agents 1-7, plus Agent 8 if `.rs` files are present, plus Agents 11 and 12 when the diff touches test code. Skip Agent 9 (cross-context), Agent 10 (adversarial fresh-context), and Agent 13 (regression-test efficacy verification). Step 3b uses a single batched verification agent for all findings instead of one per finding. | -| **3** | Every step below as written, all 13 agents, per-finding verification. The full mission-critical pass. | +| **0 (default)** | Steps 1, 2, 2.4, 2.6, 4. Skip Step 2.5 and agent fanout. Review the diff inline for correctness, NULL handling, **algorithmic optimality**, tests, and QuestDB standards. Build the Step 2.6 coverage map inline. Every candidate still passes the Step 3b admission gate inline from a blank evidence form; do not draft severity, a fix, or report prose first. | +| **1** | Adds Step 2.5a and Step 2.5e when test code is present. Run Agent 1 plus at most **two** applicable roles chosen from Agents 3, 5, 6, 12, and 13. Run an independent falsification task for each surviving atomic candidate. | +| **2** | Full Step 2.5, with 2.5b restricted to `public`/`protected` symbols. Run Agent 1 plus at most **four** change-relevant roles from Agents 2-8 and 11-13. Run an independent falsification task for each surviving atomic candidate. | +| **3** | Full Step 2.5 and the complete admission protocol. Select at most **six** applicable discovery roles from Agents 1-14: Agent 1 always; Agent 9 for changed symbols with out-of-diff callers; Agents 2-8 and 11 only when their domain is touched; Agents 12-14 only for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from producer/reachability evidence and independent falsification, not agent count. | State the chosen level in one line at the start of the review so the user knows what they're getting (e.g., "Reviewing PR #1234 at level 2"). If the level was defaulted, mention that level 3 exists for full review. +## Spawning review agents + +Steps 3 and 3b use fresh-context agents through the Agent tool, one task +per role or atomic falsification candidate. Each task is self-contained and read-only. +Discovery tasks receive the diff, Step 2.4 provenance verdicts, the Step 2.5 surface +map, the Step 2.6 coverage map, role instructions, and the candidate contract. Agents +10 and 11 are deliberate reduced-context exceptions. Step 3b falsifiers receive only +the neutral proposition, revision identities, relevant files, and raw artifact paths. +The parent owns role selection, the private ledger, admission, severity, and output. + +Use a shared temporary artifact for large maps rather than pasting them repeatedly. +Never pass the discovery narrative, proposed severity/fix, votes, or verification +claims to a falsifier. Agents 10 and 11 receive only the diff and changed-file names, +as their role descriptions require. The parent owns synthesis, deduplication, and the +final report; children return candidates or falsification evidence only. + ## Step 1: Gather PR context -Capture the PR identifier in `$PR` (the part of `$ARGUMENTS` left after stripping the level token), then fetch metadata, diff, and review comments in a single bash call so `$PR` is in scope for all three `gh` invocations: +Every mode must end this step with **`$BASE`** set — the commit the change is measured against. `$BASE` is required by Step 2.4 and by every behavioral finding's same-trigger base check; a review that never established it cannot attribute anything. + +### GitHub PR + +Capture the PR identifier in `$PR` after stripping the level token, then fetch metadata, diff, comments, and the base revision: ```bash -PR='' +PR='' gh pr view "$PR" --json number,title,body,labels,state gh pr diff "$PR" -gh pr diff "$PR" --numstat # binary files show as `--` gh pr view "$PR" --comments +BASE=$(gh pr view "$PR" --json baseRefOid --jq .baseRefOid) +``` + +### Local range (`--range`) + +When `--range=..` is given there is no PR, no description, and no +labels. Take the diff from Git instead: + +```bash +BASE='' +HEAD='' +git diff "$BASE"${HEAD:+"...$HEAD"} --stat +git diff "$BASE"${HEAD:+"...$HEAD"} +git diff "$BASE"${HEAD:+"...$HEAD"} --name-only ``` -**Committed-binary gate (runs at every level).** Scan the `--numstat` output for -any added/modified file git reports as binary (`-`/`-` in the added/deleted -columns). This repo builds its native/C libraries from source in CI and does not -commit build outputs, so any such file is a **Critical** finding regardless of -review level — report it even at level 0. See the "Committed build artifacts" -checklist for the rationale and the acceptable-exception (genuine test-input -fixtures only). +With `` empty the diff includes uncommitted working-tree changes, which is +the normal case when reviewing a `fix-pr` result before it is pushed. Untracked +files do not appear in `git diff` — list them with `git status --porcelain` and +read any that are part of the change, especially new test files, or the coverage +map in Step 2.6 will silently miss them. + +In range mode: **skip Step 2 entirely** (there is no title or description to +check) and say so in the report. Every other step runs unchanged — the diff is +still the entry point, callsite analysis still walks outward beyond the changed +files, and findings are still classified by the same rubric. Restricting the +review to the changed files would disable the out-of-diff breakage analysis that +is the most valuable part of this skill. ## Step 2: PR title and description +**Skipped in `--range` mode** — a local range has no PR metadata. State that it was skipped and continue at Step 2.4. + Check against CLAUDE.md conventions: - Title follows Conventional Commits: `type(scope): description` -- Description repeats the verb (e.g., `fix(sql): fix ...` not `fix(sql): DECIMAL ...`) -- Description speaks to end-user impact, not implementation internals +- Description repeats the verb and explains user impact - If fixing an issue, `Fixes #NNN` is at the top of the body -- Tone is level-headed and analytical, no superlatives or bold emphasis on numbers +- Tone is level-headed and analytical, with no superlatives or bold emphasis on numbers - Labels match the PR scope (SQL, Performance, Core, etc.) +- Bundled related fixes are allowed; do not demand a split + +## Step 2.4: Submodule provenance (mandatory at every level) + +A changed submodule pointer is not automatically a change this PR makes. Before reviewing **any** content inside a submodule, classify the pointer move. This step is cheap, runs at every level including 0, and gates whether an entire repository's worth of diff is in scope. Skipping it is how a review attributes months of already-released upstream work to the PR in front of it. + +List the pointer moves, then for each one resolve the submodule's default branch and test whether the new commit is already on it: + +```bash +git diff "$BASE...HEAD" --submodule=short | grep -E '^(diff --git|[+-]Subproject commit)' + +cd +git fetch origin --quiet +DEF=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||') # e.g. main, master +git merge-base --is-ancestor "origin/$DEF" && echo UPSTREAM-SYNC || echo OFF-DEFAULT +git branch -r --contains +``` + +Classify each pointer move as exactly one of: + +- **UPSTREAM-SYNC** — the new commit is an ancestor of the submodule's default branch. The work inside it **already landed upstream**; this PR only advances the pointer to pick it up. **Its contents are not in-diff and are not this PR's responsibility.** Do not review them as changes, do not attribute their behaviour changes, breaking or otherwise, to this PR, and do not build findings out of them. The only legitimate finding here is a genuine *integration* defect: the code in this diff calls the newly-synced code incorrectly. That finding lives at the callsite in this diff, not inside the submodule. +- **OFF-DEFAULT** — the new commit exists only on a feature or PR branch. The submodule's changes **are** part of this logical change and are reviewed in-diff. +- **UNRESOLVED** — the branch cannot be determined (no network, shallow clone, missing remote). Say so explicitly in the report, treat it as OFF-DEFAULT for safety, and state that the scope decision was made without provenance. + +Record the verdict per submodule in one line each, and repeat it in the Step 4 report so the scope decision is auditable. Nested submodules are classified independently: an OFF-DEFAULT OSS pointer says nothing about the client pointer nested inside it, which is frequently an UPSTREAM-SYNC in the same PR. ## Step 2.5: Map the change surface -Before launching review agents, produce a structured change surface map. This step is mandatory and must use Grep/Glob — do not reason about callsites from memory. The output of this step is required input for every agent in Step 3. +Before launching review agents, produce a structured change surface map. This step is mandatory and must use Grep and Glob — do not reason about callsites from memory. The output of this step is required input for every agent in Step 3. + ### 2.5a Semantic delta per changed symbol @@ -90,7 +179,7 @@ For every modified or added function, method, trait, struct field, SQL operator/ ### 2.5b Callsite inventory -For every changed symbol that is `public`, `protected`, package-private, or exported (`pub` / `pub(crate)` in Rust), run Grep across the entire repository to find every callsite, implementation, override, or reference outside the diff. +For every changed symbol that is `public`, `protected`, package-private, or exported (`pub` / `pub(crate)` in Rust), run `rg` across the entire repository to find every callsite, implementation, override, or reference outside the diff. Produce a list grouped by file. For Java, also search for: - subclasses that override the method @@ -105,7 +194,8 @@ For Rust, also search for: - JNI exports and their Java callers - `extern "C"` boundaries -A changed `pub`/`protected`/package-private symbol with zero recorded Grep calls in the trace is a skill violation. The model is not allowed to assert "this is only used here" without showing the search. + +A changed `pub`/`protected`/package-private symbol with zero recorded `rg` calls in the trace is a skill violation. The model is not allowed to assert "this is only used here" without showing the search. ### 2.5c Implicit contract list @@ -124,46 +214,118 @@ For each changed symbol, walk this checklist and write one line per item, statin ### 2.5d Cross-context exposure list -End this step with an explicit list of "places this change is visible from but the diff does not touch". This is the highest-priority input for the bug-hunting agents in Step 3. +End this step with an explicit list of "places this change is visible from but the diff does not touch". This is the highest-priority input for the bug-hunting subagents in Step 3. The list groups the callsites from 2.5b by execution context: hot data paths, SQL compilation, async runtime, JNI boundary, replication, materialized views, parallel execution workers, etc. Every entry on this list must be reviewed in Step 3. + ### 2.5e Test surface & helper inventory -Run this only when the PR adds or changes test code. It is the test-code counterpart to 2.5b and feeds Agents 11-13. Use real Grep/Glob searches — do not reason about helpers from memory. +Run this only when the PR adds or changes test code. It is the test-code counterpart to 2.5b and feeds Agents 12-14. Use real Grep/Glob searches — do not reason about helpers from memory. -- **Existing-infrastructure inventory:** search the changed test files' package and module for base test classes, shared `@Before`/`@After`, helper methods, fixtures, and assertion utilities the new tests could reuse (Grep for `extends Abstract.*Test`, `class .*TestUtils`, `assertMemoryLeak`, `assertQuery`, `assertSql`, shared `protected` helpers in the base class). This list is the baseline Agent 12 uses to flag reinvented boilerplate — a "you stamped boilerplate instead of reusing helper X" finding requires X to appear in this inventory. +- **Existing-infrastructure inventory:** search the changed test files' package and module for base test classes, shared `@Before`/`@After`, helper methods, fixtures, and assertion utilities the new tests could reuse (`rg` for `extends Abstract.*Test`, `class .*TestUtils`, `assertMemoryLeak`, `assertQuery`, `assertSql`, shared `protected` helpers in the base class). This list is the baseline Agent 13 uses to flag reinvented boilerplate — a "you stamped boilerplate instead of reusing helper X" finding requires X to appear in this inventory. - **Changed shared helpers as symbols:** if the PR changes a shared test base class, helper, or fixture, run the 2.5b callsite inventory for it too — a changed test base class can silently break every subclassing test. -- **Exercised-symbol map:** for each new or changed test, list which production symbols from 2.5a it actually exercises, so Agents 11 and 13 can check efficacy and regression value. +- **Exercised-symbol map:** for each new or changed test, list which production symbols from 2.5a it actually exercises, so Agents 12 and 14 can check efficacy and regression value. -## Step 3: Parallel review +## Step 2.6: Test coverage map (mandatory at every level) -Every agent receives: -1. The PR diff -2. The full change surface map from Step 2.5 (semantic deltas, callsite inventory, implicit contracts, cross-context exposure list) +This step runs at EVERY review level, for EVERY PR that touches production code — including (especially) PRs that add or change no test code at all. A PR with zero test changes does not skip test scrutiny; it concentrates it here. At level 0, derive the behavioral-change rows directly from the diff (2.5a is skipped); at level 1+ use the 2.5a semantic deltas. -### Anti-anchoring directive (applies to all agents) +Build a coverage table with one row per behavioral change: every changed symbol whose delta is not "no behavioral change", broken down further by every new or changed branch, error path, and NULL/boundary case inside it. For each row, record: -- **Bugs at callsites outside the diff outrank bugs inside the diff.** A confirmed bug in a file the PR did not touch but that calls a changed symbol is a P0 finding. -- **"Looks correct in isolation" is not a valid conclusion.** Before clearing a changed symbol, the agent must walk the callsite inventory from 2.5b and explicitly state, per callsite, whether the new behavior is still correct there. -- **The diff is the entry point, not the scope.** If the change surface map shows the symbol is reachable from N other files, the review covers N+1 files. -- A single finding of the form "in `FooReader.java` the new behavior of `Bar.x()` causes Y" is worth more than five findings inside the diff. +- **Change:** symbol + the specific behavior/branch/path. +- **Test:** the exact test class and method that exercises it — found via real `rg`/`fd` searches across the test tree (search for the symbol name, the SQL function/operator name, the error message text, the config key). Citing a test without a recorded search command in the trace is a skill violation, same as 2.5b. "Existing tests probably cover it" is banned. +- **Failure link:** what that test asserts and why the assertion fails if this behavior regresses. "The test calls the method" is not a failure link. +- **Reachability / population:** the supported operation, configuration, or event that reaches the changed path and the users/operators affected. "Public" or "user-visible" is not a population. +- **Credible regression consequence:** a concrete plausible mutation or recurrence and what that population would observe. Distinguish material harm from cosmetic output, routine verbosity, or developer-only inconvenience. +- **Change risk:** semantic complexity, branch/callsite breadth, state/concurrency/resource sensitivity, and the strength of existing surrounding coverage or compile-time/downstream safeguards. +- **Stable test design:** the least invasive assertion and observation seam considered, including cheaper unit, integration, fault-injection, or existing-helper alternatives. +- **Effort / fragility evidence:** setup cost, production seams required only for testing, global-state or asynchronous log capture, timing/nondeterminism, platform dependence, and alternatives searched. Bare "hard to test" is not evidence. +- **Dimensions:** applicable happy, error, NULL, boundary, concurrency, and resource-cleanup dimensions, each covered / uncovered / N-A with a reason. +- **Disposition rationale:** why the row is `COVERED`, `CRITICAL GAP`, `MODERATE GAP`, `ACCEPTED GAP`, or `EXEMPT`. -### Agents +Rows with no effective test are marked **UNTESTED**, then classified by evidence rather than category: -Launch the following agents in parallel. +- **Critical gap (blocking):** only when the changed path and affected population are supported and reachable, a credible regression would cause a material Critical consequence (data loss/corruption, security failure, outage/hang, compatibility break, unbounded resource loss, or similarly material operator harm), existing controls do not contain that risk, and the row passes Step 3b. The label "bug fix", "public API", "user-visible", "concurrency", or "security" never makes a gap Critical by itself. +- **Moderate gap:** meaningful but bounded regression exposure, including most bug fixes without a regression test, internal/error paths with a distinct but non-Critical consequence, or material uncertainty that does not meet the Critical burden. +- **Accepted gap:** low-risk, localized or mechanical behavior where recorded analysis shows that the least invasive meaningful test is disproportionate or more fragile than the code under test and existing safeguards make residual user risk small. Example: a routine log-level correction whose stable assertion would require invasive global asynchronous log capture. Keep the rationale private unless it is material to the verdict. +- **Exempt:** verified no-behavioral-change rows (pure rename, dead-code removal, comment/doc/CI-only). -**Agent 1 — Correctness & bugs:** NULL handling, edge cases, logic errors, off-by-one, operator precedence, error paths. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. When the diff touches the store-and-forward sender, the async drainer / send loop, primary reconnect/failover, or pool startup (`lazy_connect` / `initial_connect_retry` / `SenderPool` / `QueryClientPool`), also verify the "Store-and-forward & pool startup invariants" checklist — a running drainer that propagates a transport error to the caller, imposes a reconnect time budget, or hard-fails on a transient outage is a Critical (data-loss) finding. +A bug-fix label or zero test changes triggers this analysis; neither predetermines severity or verdict. Urgency cannot waive an actual defect or an admitted Critical gap. The coverage map is required internal evidence for Agent 5, Agents 12-14, and the Step 4 test gate. Publish only admitted gaps; keep COVERED, ACCEPTED, EXEMPT, and omitted rows private unless the user asks. At level 0, rows may be per-symbol to bound cost, but new error/exception and NULL/boundary paths still get separate rows. -**Agent 2 — Concurrency:** Race conditions, shared mutable state, missing volatile, lock ordering, thread-safety of data structures. Use the implicit contract list (lock order, thread-affinity) and check every callsite from 2.5b for violations of the new contract. +## Step 3: Change-specific candidate discovery + +Run this step with the Agent tool using fresh-context agents. Select only roles whose domain is materially touched, obey the level's discovery cap, and launch those roles as fresh-context, read-only `reviewer` tasks. Agent count is never evidence and unused roles are skipped. + +Every selected agent receives: +1. The PR or local-range diff +2. The full change surface map from Step 2.5 (semantic deltas, callsite inventory, implicit contracts, cross-context exposure list) +3. The test coverage map from Step 2.6 -**Agent 3 — Performance & allocations:** Regressions, zero-GC violations, `java.util.*` collections vs `io.questdb.std`, string creation/concatenation on hot paths, SIMD opportunities. Algorithmic complexity: for each new loop, traversal, or data structure, analyze how it scales with data size (row count, partition count, join fan-out). Flag any O(n^2) or worse patterns that could regress on large tables (1M+ rows, 1000+ partitions). Check whether new code paths are compile-time-only or data-path — compile-time allocations are acceptable, data-path allocations are not. For changed symbols now reachable from new contexts (per 2.5d), check whether any of those new contexts is a hot path. +The diff plus surface map can be large — write them to a shared file (e.g., under a temp/chain dir) and point each task at it via its `reads`/task text, rather than pasting the whole payload into every task. Agents 10 and 11 are deliberate exceptions and receive reduced context (see their entries). -**Agent 4 — Resource management:** Leaks on all code paths (especially errors), try-with-resources, native memory, pool management. Walk every callsite from 2.5b that constructs, owns, or transfers ownership of changed types and verify cleanup on all paths. +### Candidate-discovery directive (applies to all agents) -**Agent 5 — Test coverage:** Coverage gaps, error path tests, NULL tests, boundary conditions, regression tests exist, `assertMemoryLeak()` usage. Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. Missing tests for cross-context callsites is a high-priority finding. Test *efficacy* (whether those tests actually exercise the change and could fail) and test-*code* quality are handled by Agents 11-13 — here focus only on whether coverage exists for every new or changed path. +- You are a **hypothesis generator**, not an authority to publish a finding. Output atomic propositions for independent falsification. Do not assign severity, propose fixes, write persuasive titles, or use “verified”, “proved”, or “confirmed”. Any role text below that mentions a finding or severity describes what to inspect, not what you may conclude. +- For each candidate, cite the exact changed hunk or unchanged callsite contract allegedly broken. Out-of-diff impact is valuable only after the PR-caused contract delta is established. +- Name the **supported-state producer**: the exact user operation, configuration, writer/version, event source, or code path that creates every required trigger condition. If you cannot locate it, write `producer: unknown`; do not invent a deployment or state. +- Give the reachability chain, head observation, same-trigger merge-base observation, user-visible symptom, and raw evidence paths/commands. Mark anything not actually checked as `unknown`. +- Actively seek disproof: unsupported/experimental status, absent format writer, omitted caller, retry, guard, lock, validation, downstream recovery, or unchanged/better base behavior. Record the strongest counterevidence. +- Claims containing **never**, **only**, **exactly one**, **no retry**, or equivalent universal negatives require an exhaustive caller/event-source inventory, not one traced path. +- A proposition with no independent consequence is evidence for its parent candidate, not a standalone candidate. If the parent falls, its dependent propositions fall with it. +- Pre-existing bugs and residual hardening whose same-trigger behavior is unchanged or better than base are outside this PR's findings and verdict. Never file them as findings and never propose them as changes to this PR. A fully proved one leaves as a Step 4 adjacent issue draft; an unproved one stays in the private ledger. +- Two agents repeating the same reasoning are one hypothesis, not corroboration. Corroboration requires independent evidence types and still does not bypass Step 3b. +- Returning no candidate is valid and preferred to returning a speculative one. -**Agent 6 — Code quality & standards:** Code smell, member ordering, naming conventions, modern Java features, dead code, third-party dependencies. Also scan the diff for any committed compiled binary / build artifact (run `git diff --numstat`/`--stat` and flag files git reports as binary) — the native/C libraries are built from source in CI, so a committed binary is a **Critical** finding (see the "Committed build artifacts" checklist). +### Agents + +Use the following as a role catalog. Select only the roles allowed by the chosen level and change surface; do not launch the whole catalog. + +**Agent 1 — Correctness & bugs:** NULL handling, edge cases, logic errors, off-by-one, operator precedence, error paths. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. When the diff touches the QWP ingress / role-gating path, an in-place switch or failover, a `questdb` submodule bump that carries client or ingress changes, or the `questdb-ent/e2e` failover/switch suites, also verify the "Store-and-forward & pool startup invariants" checklist — a change that lets a running SF drainer surface transport errors to the producer, imposes a reconnect time budget on it, or hard-fails it on a transient outage is a Critical (data-loss) finding. + +**Agent 2 — Concurrency:** Race conditions, shared mutable state, missing volatile, lock ordering, thread-safety of data structures. Use the implicit contract list (lock order, thread-affinity) and check every callsite from 2.5b for violations of the new contract. + +**Agent 3 — Performance & algorithmic optimality:** This agent enforces the principle that QuestDB code must use the +best known algorithm for each task — not merely "avoid quadratic." + +For every new or changed loop, traversal, data structure, or computation: + +1. **Algorithm optimality:** State the time complexity. Then ask: does a better algorithm exist? O(n) where O(1) is + achievable (hash lookup vs linear scan, direct indexing vs search) is a finding. O(n log n) where O(n) suffices is a + finding. The bar is not "avoid quadratic" — the bar is "use the best known approach." +2. **Multi-pass vs single-pass:** If the code makes multiple passes over the same data (parsing, validation, + transformation), determine whether they can be fused into a single pass. Multiple passes over the same input is a + finding unless each pass has a structural dependency on the output of the previous one. +3. **Redundant computation:** Flag values that are recomputed on every call but could be computed once and cached. Flag + repeated lookups of the same key. Flag re-parsing of already-parsed data. +4. **Data structure choice:** For each collection or map, ask whether the chosen data structure is optimal. Linear + search through a list where a hash set gives O(1) membership test. Sorted array where a heap gives better + insert/extract-min. ArrayList where a direct-indexed array suffices. +5. **Unnecessary copies and conversions:** Copying data that could be referenced in place. Converting between + representations (String ↔ CharSequence, byte[] ↔ DirectByteCharSequence) when the original form would work. +6. **Zero-GC violations:** `java.util.*` collections vs `io.questdb.std`, string creation/concatenation on hot paths, + capturing lambdas, autoboxing. Even a single GC allocation on a per-row data path is a finding. +7. **SIMD and vectorization:** Where the code processes arrays or columns element-by-element, check whether a + SIMD/vectorized alternative exists in QuestDB's native layer or could be added. +8. **Compile-time vs data-path:** GC allocations during SQL compilation are acceptable. Algorithmic inefficiency during + compilation is still a finding — slow compilation means slow first-query latency — but its severity depends on + whether the cost scales: a multi-pass parse or O(n^2) plan enumeration is serious; a bounded fixed cost paid once per + compilation is a minor-impact finding. Report both, distinguished by item 9. + +9. **Magnitude (required on every performance finding):** state what the cost multiplies by — rows scanned, values + converted, pages read, partitions opened — or, if it does not scale with data, the fixed bound that caps it (column + count, config-key count, once per query compilation, once at startup). Say plainly whether the cost is on a data path + the user waits for, or off it. The parent uses this to assign severity: scaling-with-data costs block the merge, + bounded off-path costs do not. A finding with no magnitude cannot be classified and will be dropped. + +For changed symbols now reachable from new contexts (per 2.5d), check whether any of those new contexts is a hot path +that amplifies an otherwise-acceptable cost. + +**Agent 4 — Resource management:** Leaks on all code paths (especially errors), try-with-resources, native memory, pool management. Walk every callsite from 2.5b that constructs, owns, or transfers ownership of changed types and verify cleanup on all paths. When the diff adds or changes a native allocation site, also apply the "Per-query memory tracker integration" checklist below: confirm large, unbounded, data-scaled allocators are wired into the per-query `MemoryTracker` and bounded / process-lived ones are deliberately left out, that malloc and its matching free charge the same tracker, and that newly wired sites have breach / success / leak-loop tests. + +**Agent 5 — Test review & coverage:** Coverage gaps, error path tests, NULL tests, boundary conditions, regression tests, test quality, `assertMemoryLeak()` usage. Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. For each missing cross-context test, add an `UNTESTED` Step 2.6 row; do not predetermine its severity or publication. Consume the Step 2.6 coverage map: re-verify every claimed test and failure link (read the assertion, don't trust the map), and hunt for behavioral changes the map missed. Then run a **mutation spot-check**: pick the 3-5 most dangerous changed lines (boundary comparisons, error handling, null checks, off-by-one candidates) and ask, per line, "which test fails if this line is wrong — inverted condition, off-by-one, dropped null check?" When no assertion would catch a mutation, add an `UNTESTED` map row even if a test nominally executes the line; classify it under Step 2.6 and publish it only after Step 3b admission. **Enforce the "SQL test assertions (builder API — strict)" checklist on every added/modified test line: any new `assertSql(...)`/`assertPlanNoLeakCheck(...)`/`getPlan(...)`/`TestUtils.assertSql(...)` is Critical; any new `.returnsOnce(...)` on a deterministic (non-RNG, non-time-varying) query is Critical; a lone `assertQuery(...)` wrapped in `assertMemoryLeak(...)` is a finding.** Test *efficacy* (whether tests actually exercise the change and could fail) and test-*code* quality are handled by Agents 12-14 — here, focus only on whether coverage exists for every new or changed path. + +**Agent 6 — Code quality & standards:** Code smell, member ordering, naming conventions, modern Java features, dead code, third-party dependencies. **Also check for unclosed LOG statements**: QuestDB logging uses a builder pattern (`LOG.info().$("msg").$()`) and every chain MUST end with `.$()` or `.I$()`. A missing close holds a ring buffer slot forever, causing other log producer threads to busy-wait in `nextBully()`, and the log consumer `logging_0` thread cannot progress either. Also watch for `.put()` instead of `.$()` in LOG chains — `.put()` returns `Utf16Sink`, not `LogRecord`, breaking the chain. Also flag throw-capable expressions inside LOG chains (`LOG.info().$(func()).$()`): arguments are evaluated after the ring slot is acquired, so a throwing `func()` unwinds past the terminator and leaks the slot; the call must be hoisted into a local before the chain starts. **Agent 7 — PR metadata & conventions:** Title format, description quality, commit messages, labels, SQL style in tests. @@ -183,24 +345,50 @@ error propagation. Flag every potential panic site. - For changed Rust types with trait impls: do all impls still satisfy the new invariants? - For changed JNI signatures: do all Java callers pass the right types and lifetimes? -This agent's output is structured per callsite, not per failure mode. Each callsite gets a verdict: SAFE / BROKEN / NEEDS VERIFICATION. Every BROKEN entry is a P0 finding regardless of whether the file is in the diff. +This agent's output is structured per callsite, not per failure mode. Each callsite gets a verdict: SAFE / CANDIDATE / INSUFFICIENT_EVIDENCE. A CANDIDATE is only an atomic hypothesis for Step 3b; it has no severity yet. -This agent is not optional even when the diff is small. Small diffs to widely-used symbols have the largest blast radius. +Select this role whenever changed symbols have meaningful out-of-diff callers. It counts toward the level's discovery cap; small diffs to widely used symbols usually justify it. **Agent 10 — Fresh-context adversarial:** Dispatched separately from agents 1-9 to escape checklist anchoring. This agent operates under different rules from the rest: - It receives ONLY the PR diff and the names of the changed files. It does NOT receive the change surface map from Step 2.5, the implicit contract list, the cross-context exposure list, or any of the review checklists below. -- Its sole instruction: "find ways this code is wrong". No category list, no failure-mode taxonomy, no QuestDB-specific style guide. -- It is free to use Read, Grep, and Glob to explore the repository however it wants. -- Findings are not pre-classified by category. Each finding states: what's wrong, why it's wrong, and the code path that demonstrates it. - -The point of this agent is to surface bugs the structured agents cannot see because they are reasoning inside the same frame. A finding here that none of agents 1-9 produced is high signal — it means the structured review missed it. A finding here that overlaps with agents 1-9 is corroboration. - -Run this agent in parallel with agents 1-9. It is mandatory regardless of diff size. - -**Test-code agents (Agents 11-13) — run only when the diff adds or changes test code.** Launch them in the same parallel batch as agents 1-10. Each receives the diff, the change surface map, and the test surface inventory from 2.5e. They are the test-code counterparts to the production agents: Agent 11 mirrors Agent 1 (correctness), Agent 12 mirrors Agent 6 (code quality), and Agent 13 verifies regression-test efficacy. Tests are not second-class code — apply the same adversarial rigor here as to production. - -**Agent 11 — Test efficacy & correctness (adversarial):** Prove each test actually exercises the production change and could fail if that change regressed. +- Its sole instruction: “generate a small set of falsifiable ways this code could be wrong, and try to disprove each before returning it.” No category list, failure-mode taxonomy, or QuestDB-specific style guide. +- It is free to use Read and ripgrep (Grep/Glob with Bash) to explore the repository however it wants. +- Each surviving output follows the candidate contract: atomic proposition, changed attribution, producer, reachability, head/base observations, symptom, counterevidence, and missing evidence. No severity or fix. + +The point is to escape the structured frame, not to create privileged findings. A unique hypothesis is not high signal by itself, and overlap is not corroboration unless it supplies an independent evidence type. + +Select this role only when a distinct adversarial pass is warranted; it counts toward the level's discovery cap. + +**Agent 11 — Adversarial performance:** Dispatched separately from Agent 3 to escape checklist anchoring. This agent +operates under different rules: + +- It receives the PR diff plus the full source files that the diff touches (not just the changed lines). It does NOT + receive the performance checklist, the change surface map, or Agent 3's findings. +- For every function or method the diff adds or modifies, read the full implementation and ask one question: **"What is + the theoretically fastest way to implement this, and does the code match it?"** +- Work bottom-up from the code, not top-down from a checklist. Trace data flow through each function: what is read, how + many times, in what order. Look for: + - Passes over data that could be eliminated or fused + - Lookups that could be O(1) but aren't + - Allocations that could be avoided by reusing buffers + - Branching that could be replaced with branchless arithmetic + - Scalar loops over column data that could be vectorized + - Sorting or searching where the input has structure (sorted, partitioned, bounded) that the code ignores + - Work done unconditionally that is only needed conditionally + - Intermediate collections built and then iterated once (build + iterate = two passes; a single streaming pass may + suffice) +- Use Read and ripgrep (Grep/Glob with Bash) freely. Read callers to understand actual input sizes and access patterns — an O(n) scan that + runs once at startup is different from one that runs per row. +- Each finding states: what the code does now (with complexity), what the optimal approach is (with complexity), and why + it matters (call frequency, data scale, or hot-path placement). +- Do not duplicate zero-GC or style findings — focus purely on algorithmic and computational efficiency. + +Select this role only when the diff changes loops, algorithms, data structures, allocation behavior, or a plausible hot path; it counts toward the level's discovery cap. + +**Test-code agents (Agents 12-14) — eligible only when the diff adds or changes test code or claims a bug fix.** A production change with no test code is still handled by the Step 2.6 gate. Select only the applicable test roles within the level's discovery cap. Each receives the diff, the change surface map, and the test surface inventory from 2.5e. Tests are not second-class code — apply the same adversarial rigor here as to production. + +**Agent 12 — Test efficacy & correctness (adversarial):** Prove each test actually exercises the production change and could fail if that change regressed. - **Vacuous assertions:** flag every assertion that cannot fail — `assertTrue(true)`, `assertFalse(false)`, `assertEquals(x, x)`, asserting a literal against the same literal, asserting on a value the test itself just hard-coded, or a `@Test` body with no assertion and no `expected=`/`assertThrows`. - **Tests that don't reach the changed code:** the assertion passes whether or not the production change is present. Trace the data flow from the changed symbol to the assertion. - **Happy-path-only:** no assertion on the error/exception/NULL path the production change added. @@ -208,22 +396,53 @@ Run this agent in parallel with agents 1-9. It is mandatory regardless of diff s - **Test setup/teardown resource handling:** native memory allocated in setup/`@Before` that leaks on a failing path, missing `assertMemoryLeak()` wrapping. - Each finding states the exact assertion and why it cannot fail or what it fails to cover. -**Agent 12 — Test-code quality & maintainability:** Review the test as code. +**Agent 13 — Test-code quality & maintainability:** Review the test as code. - **Reflection overuse:** flag `setAccessible(true)`, `getDeclaredField`/`getDeclaredMethod`, `Field.set`, `Class.forName`, and similar when a public API, an existing test helper, or a constructor reaches the same state. Reflection in tests is a last resort; if a neater non-reflective path exists, the reflection is a finding — name the alternative. - **No code reuse / boilerplate stamping:** before accepting repeated setup or assertion blocks, run Grep/Glob for existing helpers, base test classes, and fixtures (e.g., `extends Abstract.*Test`, `TestUtils`, `*TestUtils`, shared `assert*`, shared `@Before`) using the 2.5e inventory. If a helper already exists that the new test reimplements inline, flag it and name the helper. Duplicated blocks across new test methods that should be a single helper or a parameterized test are findings. - **Javadoc bloat:** flag multi-paragraph javadoc on `@Test` methods, javadoc that merely restates the test name, and stacked/duplicated javadoc ("javadoc piled on javadoc"). Test intent belongs in a precise test name plus, at most, a one-line comment. - **Residue and smells:** dead code, commented-out code, copy-paste leftovers (a `testFoo` that actually tests bar), `System.out.println` debugging, `@Ignore` without a referenced ticket, magic numbers >= 5 digits without `_` separators. - **Which standards apply:** zero-GC and `io.questdb.std`-over-`java.util` do NOT apply to test code — do not flag `java.util` collections or allocations in tests. Member ordering, `is/has` boolean naming, and SQL style DO apply. -**Agent 13 — Regression-test efficacy verification:** For any PR that claims to fix a bug, verify the regression test would actually fail without the production change. Reason about reverting the production hunk and confirm the new or changed test's assertions would then fail. If the test still passes with the fix reverted, it is not a regression test — flag it. State, per test, which production line the test depends on and what its assertion would do if that line were reverted. Run only when the PR is a fix; skip for pure features or refactors. +**Agent 14 — Regression-test efficacy verification:** For any PR that claims to fix a bug, verify the regression test would actually fail without the production change. Reason about reverting the production hunk and confirm the new or changed test's assertions would then fail. If the test still passes with the fix reverted, it is not a regression test — flag it. State, per test, which production line the test depends on and what its assertion would do if that line were reverted. Run only when the PR is a fix; skip for pure features or refactors. + +Combine agent outputs into a private **candidate ledger**. Split compound narratives into atomic propositions, deduplicate by proposition plus evidence, and record dependencies. Do not draft report prose, severity, or a suggested fix. A candidate is not a finding. + +## Step 3b: Independently falsify, prove, and admit candidates + +Use this state machine with no shortcuts: + +`HYPOTHESIS → FALSIFYING → PROVEN → ADMITTED` -Combine all agent findings into a single deduplicated **draft** report. Do NOT present this draft to the user yet — it goes straight into verification. +Any missing proof, unresolved contradiction, failed reproduction, unsupported producer, or dependence on an omitted premise ends at `OMITTED`. There is no `DOWNGRADED` state for an unproven behavioral claim, and “could not disprove” never means `PROVEN`. -## Step 3b: Verify every finding against source code +At levels 1-3, launch one fresh-context falsifier per atomic candidate. The falsifier receives only (a) the neutral proposition, (b) target repository, base/head revision identities (commit SHAs, or a captured diff hash for an uncommitted working tree) and relevant file names, and (c) raw evidence/artifact paths. **Do not send** the discovery narrative, proposed severity, suggested fix, author identity, other agents' votes, or statements that the claim was verified. At level 0, the parent applies the same protocol inline from a blank evidence form before writing any report prose. -The parallel review agents work from the diff plus the change surface map and frequently produce false positives — especially around memory ownership, polymorphic dispatch, Rust control-flow guarantees, and JNI lifecycle conventions. Every finding MUST be verified before it is reported. +The falsifier's first task is to construct the strongest disproof: find a missing state producer, unsupported deployment, impossible version/format pairing, omitted caller or event source, retry, guard, lock, validation, downstream offset, or identical/better base behavior. Only if the candidate survives does it assemble affirmative proof. -For each finding in the draft report: +A behavioral candidate is admitted only when every field below is backed by cited evidence: + +- **Attribution:** exact changed hunk, or exact unchanged callsite plus the contract this PR changed. +- **Supported-state producer:** exact supported operation/configuration/writer/version/event that creates every trigger condition. A reachable consumer branch is not proof that any producer can create its input. +- **Reachability:** complete producer-to-symptom path, including callers, event sources, retries, guards, locks, and offsets. +- **Head observation:** executed trigger and observed output/state at the reviewed revision. +- **Base observation:** the identical trigger and observed output/state at `$BASE`, or `N/A — genuinely new surface` with proof. +- **User symptom:** independently observable consequence; a statement that merely justifies another candidate is not a finding. +- **Counterevidence search:** strongest attempted disproof and why it does not apply. +- **Artifact:** exact command/test, output, environment/config, and revision identity (commit SHA or captured diff hash). Race, ordering, retry, restart, filesystem-state, compatibility, and on-disk-format claims always require runtime evidence; static source reading alone cannot admit them. + +For static findings fully proved by source — compile errors, direct standards violations, or malformed LOG chains — mark producer/head/base/runtime fields `N/A — static` and cite the complete source proof. For a coverage gap, recorded searches may statically prove only that an effective test is absent; they never make the supported-state producer, reachability, affected population, credible regression consequence, or user impact `N/A`. A Critical coverage gap must prove those fields under Step 2.6. `N/A` is forbidden whenever a load-bearing premise concerns runtime shape, reachability, or impact. + +Special burdens: + +- A format/version/state compatibility claim must identify an actual producer that writes the alleged state in a supported deployment. A constant comparison or reader guard is not a producer. +- A claim containing **never**, **only**, **exactly one**, **no retry**, or an equivalent universal negative must include an exhaustive inventory plus an executed probe. One path proves only that path. +- A concurrency or ordering claim must force or observe the interleaving; timing prose is not evidence. +- A regression-test claim must run the test on head and against the reverted production hunk. +- If a parent premise is omitted, omit every candidate that depends on it; do not preserve its supporting propositions as Moderate findings. + +If required execution is impossible, record the validation limitation in the private ledger and omit the candidate from the public findings. Never fall back from failed or unavailable execution to confident prose. + +After a candidate satisfies this admission schema, apply the domain-specific checks below: 1. **Read the actual source code** at the exact lines cited. Do not rely on the agent's description alone. 2. **Trace the full code path**: follow callers, inheritance hierarchies, and runtime types. A method called on a base-class reference may dispatch to a subclass override (e.g., `PartitionDescriptor.clear()` vs `OwnedMemoryPartitionDescriptor.clear()`). @@ -238,29 +457,55 @@ For each finding in the draft report: 7. **For Rust numeric overflow claims**: check whether the overflow is reachable at realistic scale. QuestDB handles billions to a few trillion rows, thousands of tables, and thousands of columns — not billions of columns or quintillions of rows. If overflow requires values beyond that scale, drop it. -8. **For performance claims**: check whether the cost is measurable in a realistic scenario. Downgrade to a nit if the - saving is negligible relative to the surrounding work. Exception: GC allocations on a hot path are always worth - flagging, even a single one. +8. **For performance claims**: verify the finding is technically accurate (correct complexity analysis, correct + identification of the hot/cold path) **and then establish its magnitude**. State what the cost multiplies by — rows + scanned, values converted, pages read, partitions opened — or, if it does not scale with data, state the fixed bound + (column count, config-key count, once per query compilation). A performance claim with neither a multiplier nor a + bound is not verified. Do not drop a technically correct finding because today's tables are small — data grows. Do + move it from Critical to Moderate when the cost is structurally bounded and off the data path: that is the whole + difference between IO amplification that hits every row and a few hundred nanoseconds spent once per SQL + compilation. Sub-optimal algorithm choice is always reportable; whether it *blocks* is decided by the magnitude. 9. **For cross-context findings (Agent 9)**: re-read the callsite in full, including its callers up two levels, and confirm the broken behavior is reachable from production code paths. Cross-context findings are high-value but also the easiest to overstate — verify carefully. -10. **For test-efficacy findings (Agents 11, 13)**: re-read the cited assertion in full context and confirm it truly cannot fail — a "vacuous assertion" claim is a false positive if production code actually recomputes the asserted value. For "would pass without the fix" claims, trace what the assertion observes against the reverted production hunk before reporting. -11. **For test-code-quality findings (Agent 12)**: confirm a flagged reflective access really has a non-reflective alternative (some QuestDB internals genuinely require reflection in tests) before reporting it. Confirm a "reinvented helper" finding by actually locating the helper with Grep and checking its signature fits the test's need. -12. **For "swallowed exception → silent wrong results / leak / corrupt state" claims**: a `catch` block is defensive coding, not evidence that anything throws. Before reporting, name **all three** of: +10. **For test-efficacy candidates (Agents 12, 14)**: re-read the cited assertion in full context and confirm it can fail for the claimed regression. For “would pass without the fix” claims, use a scratch `git worktree` (never the primary working tree): run the new test at the reviewed revision, then revert the production hunks (`git checkout -- `) and run it again. Admission requires green-on-head and red-without-fix artifacts. If the environment cannot build or run the test, omit the candidate and record the validation limitation privately; do not fall back to confident reasoning. The same rule applies to every dynamic Critical candidate: execute the claimed trigger and attach the observed output, or omit it. +11. **For coverage-gap candidates (UNTESTED rows from 2.6)**: verify the recorded test search and failure-link analysis, then try to falsify the risk with existing indirect assertions, guards, type/compile guarantees, constrained inputs, downstream validation, or operational controls. Establish supported reachability, an affected population, a credible regression mode, and its material consequence before assigning Critical. Evaluate the least fragile meaningful test and concrete alternatives. Reject bare "simple", "urgent", "hard to test", or "covered indirectly" claims; test-feasibility evidence counts only when it names the proposed observation seam, why it is invasive/unstable, and why cheaper stable alternatives do not work. A Critical gap may be counterfactual about whether the code is currently wrong, but never about reachability or impact. Test difficulty does not downgrade an independently proved functional defect. +12. **For test-code-quality findings (Agent 13)**: confirm a flagged reflective access really has a non-reflective alternative (some QuestDB internals genuinely require reflection in tests) before reporting it. Confirm a "reinvented helper" finding by actually locating the helper with `rg` and checking its signature fits the test's need. +13. **For "swallowed exception → silent wrong results / leak / corrupt state" claims**: a `catch` block is defensive coding, not evidence that anything throws. Before reporting, name **all three** of: (a) the **concrete exception type** and the **exact statement** that raises it — quote the throwing line, don't infer it from the presence of a `try`; - (b) proof that this type is actually **caught by the specific catch clause cited** — `catch (SqlException | CairoException)` does NOT catch `OutOfMemoryError`, `IllegalArgumentException`, `NullPointerException`, or any other unlisted `Error`/`RuntimeException`. An `Error` that escapes the catch means the operation **fails loudly**, which inverts the finding; + (b) proof that this type is actually **caught by the specific catch clause cited** — `catch (SqlException | CairoException)` does NOT catch `OutOfMemoryError`, `IllegalArgumentException`, `NullPointerException`, or any other unlisted `Error`/`RuntimeException`. An `Error` that escapes the catch means the query **fails loudly**, which inverts the finding; (c) that the throwing statement is reachable with the arguments the callsite actually passes (constants, pre-reserved capacity, and guarded early returns frequently make it unreachable). - If any of (a)-(c) cannot be established, the finding is **not** a silent-wrong-results bug. It may still be reportable as a **latent invariant violation / hardening** item — file it that way under Moderate, state explicitly that no user-visible impact exists today, and say what future change would make it live. + If any of (a)-(c) cannot be established, omit the candidate. Do not relabel the unproven mechanism as a latent invariant or hardening finding; it may remain private supporting analysis only. Also check for the **non-throwing** sibling: a `void` method that silently drops or frees its argument on an early return (`if (x) { free(arg); return; }`) breaks the same invariant with no exception at all, is usually far more reachable than the throw, and is not fixed by reordering statements around the call. Report that path instead of, or in addition to, the throw. -13. **Verify the conjunction, not just the links.** A multi-step finding ("A publishes early → B can throw → C swallows → D reads stale → wrong result") is only as true as its weakest step, but per-line verification (item 1) confirms each step **in isolation** and will happily mark all of them CONFIRMED. Before filing any finding whose argument is a chain of three or more propositions, identify the single **load-bearing step** — the one that, if false, collapses the whole thing (usually "this can actually happen", not "this line says what the reporter says it says") — and verify **that** step first and hardest. Record it in the finding as "load-bearing step: , verified by ". A finding whose every link is individually true can still be a false positive. -14. **Verify the proposed fix compiles and closes the window.** Re-read the fix against the surrounding code before including it: check that every variable it references is still in scope and non-`null` at the point it runs (statements like `a = b = c = null;` and ownership transfers routinely invalidate "just move this call later" advice), that it does not introduce a double-free or leak in the `finally`, and that it closes **every** path identified in item 12 — not just the one the reporter noticed. A fix that doesn't compile or that leaves the real path open discredits an otherwise valid finding. -15. **Classify each finding** as: - - **CONFIRMED in-diff** — the bug is real and inside the diff - - **CONFIRMED at out-of-diff callsite** — the bug is in an unchanged file because the changed symbol is used there in a way that's now broken (cite the file and the contract from 2.5c that was violated) - - **FALSE POSITIVE** — the code is actually correct (explain why) - - **CONFIRMED with nuance** — the issue exists but is less severe than stated (explain) +14. **Verify the conjunction, not just the links.** A multi-step candidate ("A publishes early → B can throw → C swallows → D reads stale → wrong rows") is only as true as its weakest step. Identify the single **load-bearing step** — usually “this supported state can actually occur” — and try to falsify it first. Per-line support for each isolated link does not prove their conjunction. + Reading code is not verification when the load-bearing step is a runtime-shape claim — “the plan contains factory X”, “the guard does not fire”, “this branch is taken”, or any claim about races, ordering, retries, restarts, or filesystem state. Such a step requires an attached execution artifact produced or independently re-run by the falsifier at the cited revision. An agent's prose is not an artifact. Votes do not count as corroboration; even independent evidence types must still satisfy every admission field. +15. **Derive a fix only after admission, then verify it compiles and closes the window.** A plausible fix is never evidence that the finding is real. Once admitted, check that every referenced variable is in scope and non-`null`, that ownership transfers do not create a double-free or leak, and that the fix closes every admitted path. +16. **Determine net user impact, then classify.** Step 4 assigns severity only after this determination. A behavioral candidate missing it is `OMITTED` and never reaches Step 4. + + **(a) Net user impact — answer all five, in order:** + - **Population** — who reaches it: every user, every user of a named feature, a specific query/DDL/ingest shape, or an operator-only path. “Any user in principle” is not a population. If no supported user or operator population can execute the producer, omit a behavioral candidate; do not preserve it as Moderate. + - **Delta vs base** — what that population observes differently from the merge base for the identical executed trigger. Static comparison is allowed only for a fully static finding; every behavioral claim requires observed head and base artifacts at every review level. + - **Magnitude and frequency** — how much and how often: per row, per query, per restart, once ever. Reuse the 3b.8 multiplier or bound. + - **Offsets** — what recovers this downstream before the user sees anything. Code offsets: a later validation, a retry, a checksum, a caller that discards the value, a guard the same PR added elsewhere. **Process offsets count too**: an established team procedure, a merge or release convention, a CI gate, or a deployment step that resolves the condition before it can reach anyone. A state the team's normal workflow always corrects is offset — treat it as such rather than assuming the worst path is taken. Name the offset, or write "none found, searched ". + - **Net** — exactly one of: + - **net-negative** — the population is measurably worse off than base. Only net-negative behavioral candidates can be admitted. + - **net-neutral** — no observable regression versus base. Omit it from PR findings. + - **net-positive** — the population is better off than base. Omit it from PR findings. -**Move false positives to a separate "Downgraded" section** at the end of the report. For each, give a one-line explanation of why it was dismissed. This lets the PR author verify the reasoning and catch verification mistakes. + A **coverage-gap** row is counterfactual only about whether an unobserved regression currently exists. Its producer, reachable path, population, credible regression consequence, magnitude, offsets, change risk, and stable-test feasibility must be evidenced under Step 2.6. Coverage absence affects confidence; it does not manufacture impact. Static code-quality findings are assessed directly from changed lines. -Launch verification agents in parallel where findings are independent. Each verification agent should read surrounding source files, not just the diff. + A behavioral net determination missing a supported population or same-trigger base delta is not a determination. A coverage-gap Critical missing material reachable impact or test-feasibility evidence is not Critical; classify it Moderate, accept it with evidence, or omit it as the admission schema warrants. + + **(b) Classify ledger entries** as: + - **ADMITTED in-diff** — every applicable admission field is proved and the defect is inside the diff + - **ADMITTED out-of-diff-breakage** — every applicable field is proved, and an unchanged callsite is broken by a contract this PR changed + - **OMITTED pre-existing/not-attributed** — base has the same or worse behavior and this PR does not expose a new path + - **OMITTED false** — counterevidence disproves the proposition + - **OMITTED unverified** — any required producer, reachability, observation, artifact, or dependency is missing + +**Enumerated candidates are admitted per item.** Never sample N instances and publish the unverified remainder. Every rendered item needs its own producer/trigger and evidence; otherwise omit that item. + +Keep omitted candidates and their disproofs in the private ledger. Do not publish a Downgraded, retracted, rejected, or “possible issue” section, and do not report candidate counts. **OMITTED pre-existing/not-attributed** is the one exception: an entry whose producer, reachability, and observation are all proved leaves the ledger as a Step 4 adjacent issue draft. **OMITTED false** and **OMITTED unverified** entries never do. + +Fresh falsifiers may run in parallel, but each receives only its neutral proposition and raw evidence contract. The parent independently checks every returned admission form before writing Step 4. ## Review checklists @@ -279,58 +524,101 @@ Review the diff for: - Thread-safety of data structures used across threads - For every changed symbol, check whether it is now called from a thread or context (per 2.5d) where the previous concurrency assumptions don't hold -### Performance -- Performance regressions: changes that make hot paths slower or increase complexity -- Unnecessary allocations on data paths (zero-GC requirement) +### Performance & algorithmic optimality + +QuestDB is a performance-first database. On data paths the standard is not "avoid regressions" — it is "use the best +known algorithm", and a violation blocks the merge. Off data paths (SQL compilation, DDL, startup, metadata operations) +the standard is the same, but a *bounded* violation is a Moderate finding, not a blocker. Every new loop, traversal, +data structure choice, and computation must be justified as optimal or near-optimal — and every finding must say which +of the two categories it lands in, per the magnitude rule in Step 4. + +#### Algorithm optimality (highest priority) + +- For every new or changed loop/traversal, state the time complexity. Then ask: does a better algorithm exist? Flag: + - O(n) linear scan where O(1) hash lookup or direct indexing is possible + - O(n log n) sort where O(n) alternative exists + - O(n^2) nested iteration where O(n) or O(n log n) would work + - Any sub-optimal complexity where a better algorithm is known, at any scale +- Multi-pass vs single-pass: if the code traverses the same data multiple times (parsing, validating, transforming, + collecting then iterating), determine whether the passes can be fused into one. Multiple passes is a finding unless + each pass structurally depends on the completed output of the previous one. +- Redundant computation: values recomputed on every call that could be computed once and cached. Repeated map/list + lookups for the same key. Re-parsing of already-parsed data. Re-traversal of an already-visited structure. +- Data structure fitness: is the chosen data structure optimal for the access pattern? Linear search in a list where a + hash set gives O(1). Sorted array where a heap gives better insert/extract-min. Linked traversal where an indexed + array gives O(1) random access. ArrayList where a pre-sized array suffices. +- Unnecessary copies and conversions: copying data that could be referenced in place. String ↔ CharSequence, byte[] ↔ + DirectByteCharSequence conversions when the original form works. + +#### Zero-GC and allocation discipline + +- Unnecessary allocations on data paths (zero-GC requirement) — even a single GC allocation on a per-row path is a + finding - Use of `java.util.*` collections (HashMap, ArrayList, etc.) instead of QuestDB's own zero-GC collections in `io.questdb.std` - String creation or concatenation on hot paths (use CharSink, StringSink, or direct char[] instead) - Capturing lambdas on hot paths — lambdas that capture local variables or instance fields allocate a new object on every invocation. Non-capturing lambdas (static method refs, no closed-over state) are safe as the JVM caches them. Flag any capturing lambda on a data path. - Autoboxing on hot paths — primitive-to-wrapper conversions (`int` → `Integer`, `long` → `Long`, etc.) allocate silently. Watch for primitives passed to generic methods, stored in `java.util.*` collections, or returned from methods with wrapper return types. -- Missing SIMD or vectorization opportunities + +#### Vectorization and native acceleration + +- Missing SIMD or vectorization opportunities where QuestDB's native layer could process column data in bulk - Inefficient algorithms where QuestDB already provides optimized alternatives -- Algorithmic complexity at scale: for each new loop or traversal, what is the time complexity as a function of row count, partition count, or join fan-out? Flag O(n^2) or worse patterns. Consider: what happens with 1M outer rows? 10K partitions? 100-way fan-out per row? -- Compile-time vs data-path distinction: allocations and O(n) scans during SQL compilation/optimization are acceptable; the same on per-row data paths are not + +#### Compile-time paths + +- GC allocations during SQL compilation are acceptable +- Algorithmic inefficiency during compilation is still a finding — slow compilation means slow first-query latency. A + multi-pass parse, O(n^2) plan enumeration, or redundant AST traversals in the compiler are real problems. Severity + follows the magnitude rule: compile cost that scales with input (O(n^2) in column/term count, re-parsing on every + invocation, work repeated per row of a cursor) is Critical; a bounded fixed cost paid once per compilation — an extra + small allocation, a linear scan over column count, a few hundred nanoseconds — is Moderate. ### Code quality - Code smell: overly complex methods, deep nesting, unclear intent, dead code - No third-party Java dependencies on data paths -### Committed build artifacts -- **A newly committed compiled binary is always Critical.** This repo builds its - native/C libraries from source in CI (`rebuild_native_libs.yml`, - `build_native.yaml`, guarded by `check-glibc-floor.sh`) and does not commit - build outputs. A binary added or modified in the diff cannot be reviewed, - audited, or reproduced from source, can smuggle in unaudited or malicious - code, and bloats the repo history irreversibly — so it blocks the merge. -- Detect it structurally, not by extension alone: run `git diff --stat` / - `git diff --numstat` on the PR and flag every added/modified file git reports - as binary (`numstat` shows `-`/`-` for added/deleted lines; `--stat` shows a - `Bin … -> … bytes` marker). Typical offenders: `.so`, `.dylib`, `.dll`, `.a`, - `.o`, `.lib`, `.exe`, `.class`, `.jar`, `.war`, `.wasm`, `.node`, `.bin`. -- The finding stands even when the binary "looks" legitimate (e.g. a rebuilt - `libquestdb.*`): the correct source of these artifacts is the CI native-build - pipeline plus release packaging, never a PR diff. The only acceptable binaries - are genuine test-input fixtures/resources (data a test reads), not build - outputs — and even those must be justified. -- Suggested fix: drop the binary from the PR, confirm a `.gitignore` entry - covers it, and let CI native-build + release packaging produce it. - ### QuestDB coding standards -- Class members grouped by kind (static vs instance) and visibility, sorted alphabetically +- Class members grouped by kind (static vs instance) and visibility - Boolean names use `is...` / `has...` prefix - Modern Java features: enhanced switch, multiline strings, pattern variables in instanceof +### Logging +- Every LOG chain MUST end with `.$()` or `.I$()` — a missing close holds a ring buffer slot forever and stalls the `logging_0` consumer +- Watch for `.put()` instead of `.$()` in LOG chains — `.put()` returns `Utf16Sink`, not `LogRecord`, breaking the chain +- No throw-capable expressions inside LOG chains: arguments are evaluated after the slot is acquired, so `LOG.info().$(func()).$()` leaks the slot if `func()` throws — hoist into a local first (`var a = func(); LOG.info().$(a).$();`) + ### Resource management - Resources properly closed in all code paths (especially error paths) - try-with-resources used where applicable - Native memory freed correctly -### Store-and-forward & pool startup invariants (QWP facade) -Apply this whenever the diff touches the SF sender, the async drainer / send -loop, primary reconnect/failover, `SenderPool` / `QueryClientPool` startup, -`lazy_connect`, or `initial_connect_retry`. A violation here is a **Critical** -finding: the whole point of store-and-forward is that a running producer never -loses data and never hard-fails on a transient outage. +### Per-query memory tracker integration (if PR adds or changes native-memory allocation sites) + +QuestDB caps how much native memory a single bounded workload (user SQL query, materialized view refresh, WAL apply batch) may allocate through a per-query `MemoryTracker`. The tracker is bound on `SqlExecutionContext` (`getMemoryTracker()` / `setMemoryTracker(...)`) and threaded into the tracker-aware `Unsafe.malloc` / `realloc` / `free` / `getNativeAllocator(tag, tracker)` overloads (and the Rust `QdbAllocator`). A `null` tracker degrades to global-RSS-only accounting. Apply this checklist whenever the diff adds or changes a native allocation site, a factory/cursor that owns growing native buffers, or a pooled memory class (`Map`, `RecordChain`, `RecordArray`, sort/tree chains, `GroupByAllocator`, join-key maps, etc.). + +**The tracker is for large, potentially unbounded allocations only — that is the whole decision rule.** Do not treat "wire everything" as the safe default; over-wiring is itself a finding. + +- **Wire it** when the allocation grows with the data or query cardinality and has no structural cap: map / hash-table backing, sort / tree / record chains, hash-join key (and match-id) maps, the group-by allocator and aggregate function state, `LATEST BY` rowid lists and maps, set-operation maps, encoded and top-K `ORDER BY ... LIMIT N` sort buffers (parallel and single-threaded), secondary / markout-horizon cross-join buffers, window-join and horizon-join aggregation maps, window partition maps and RANGE-frame ring buffers, SAMPLE BY fill, parquet decode buffers. These are the runaway vectors the limit exists to catch. An unbounded site that passes `null` (or omits the tracker overload entirely) is a coverage-gap candidate: record the runaway query path and classify it through Step 2.6. It is Critical only when that path independently proves the required material reachable impact. +- **Leave it on the global counter only** when the allocation is structurally bounded, self-capped, or process / session-lived: page-frame buffers, JIT buffers, `string_agg`, fixed-size heaps (e.g. the single-column long top-K heap), ROWS-frame window buffers, table reader / writer columns, symbol tables, connection buffers, memory-mapped pages. Wiring one of these is a finding in its own right: it adds two atomic counter updates per malloc/free on both the Java and Rust paths for no protective benefit, and tracker-aware pooled classes give up cross-query backing retention (they free native backing on cursor close and re-allocate on next use), so charging a bounded or retained allocator to the tracker trades away a pool optimization for nothing. + +For each new or changed allocation site, verify: + +- **Same tracker for malloc and its matching free.** A site that allocates with a tracker but frees with `null` (or vice versa) desyncs the counter and trips the live `recordPerQueryMemAlloc` balance assert. Trace every free / close path — error paths and `toTop()` / `clear()` / cursor-close reuse included — and confirm the identical tracker is used on both ends. +- **Nested SQL inherits the outer tracker.** Subqueries, the mat-view refresh inner SELECT, and WAL apply inner SQL must inherit the tracker already bound on the context, not acquire their own. A new acquisition site that acquires unconditionally (instead of only when no outer tracker is present) double-counts — flag it. +- **Coverage has a test.** A newly wired allocator needs a `*MemoryTrackerTest` proving (a) a breach throws the per-query out-of-memory message, (b) an under-limit run succeeds, and (c) a `getCursor()`-to-close leak loop stays balanced. Record a missing tracker test or an unpinned factory-class routing guard as an `UNTESTED` Step 2.6 row; classify and publish it only through the normal proportionality and admission gates. + +### Store-and-forward & pool startup invariants (QWP client contract) +Apply this whenever the diff touches the QWP ingress path (upgrade/role +gating, in-place demote / lifecycle switch, connection handling on role +change), replication failover, a `questdb` submodule bump that carries +client (`java-questdb-client`) or ingress changes, or tests that drive a +producer through a failover/switch window (e.g. the `questdb-ent/e2e` +failover/switch suites). These are the CLIENT's store-and-forward +guarantees (the client code lives in the nested `questdb/java-questdb-client` +submodule); server-side changes and tests in this repo must be reviewed +against them. A violation here is a **Critical** finding: the whole point of +store-and-forward is that a running producer never loses data and never +hard-fails on a transient outage. **Drainer (steady state — once the pool is running).** - Once the pool is running, an async drainer thread ships buffered SF data to @@ -385,21 +673,31 @@ loses data and never hard-fails on a transient outage. - `lazy_connect=true`: `build()` MUST succeed with **no server present**. The producing `Sender` must work immediately (writes buffer via SF), and once the server comes up the read side must also connect and read (reads are deferred, - not disabled). Verify `build()` does not fail-fast, the sender does not throw - on the first write while the server is down, and a later `borrowQuery()` - succeeds once the server is up. + not disabled). - `lazy_connect=false` (default): `build()` / the initial connect MUST expose connectivity problems to the caller — DNS errors, connect-refused / unreachable, TLS/cert, authentication/authorization, and connect/upgrade timeouts must all surface as a thrown exception at startup, not be swallowed. - Verify each of those failure classes reaches the user during initialization. - **In BOTH modes the boundary is the same:** connectivity errors are only ever the caller's problem DURING initialization. Once the client has connected and is past initialization, the running drainer reverts to the steady-state contract above — it must NEVER expose transport problems, NEVER impose a reconnect time budget, and NEVER hard-fail on a transient outage. - Anything that undermines the store-and-forward guarantee past init is - Critical. + +**Server-side & test application (this repo).** +- The server MUST NOT rely on producer-visible role errors: an in-place + demote CLOSES QWP ingress connections (no per-write SECURITY_ERROR to an SF + sender). A server change that reintroduces per-write role errors on the QWP + ingress path breaks the containment contract above. +- Flag any test (unit, integration, or e2e) that uses QWP producer-visible + role errors as evidence of the REPLICA write gate — under the containment + contract the producer is silent by design. Write-gate evidence belongs on + pg-wire probes, frozen commit counts on the settled replica, and + post-promotion SF drain (durable-ack await barriers + dense oracles). +- Dense/count oracles over rows produced through an SF sender must account + for at-least-once replay: durably ack (await) seed rows before the + disturbance, or use a DEDUP table — otherwise the oracle reports replay + duplicates as data corruption. ### SQL conventions (if tests or SQL involved) - Keywords in UPPERCASE @@ -407,19 +705,35 @@ loses data and never hard-fails on a transient outage. - Underscores in numbers >= 5 digits (e.g., 1_000_000) - Multiline strings for complex queries - No DELETE statements (suggest DROP PARTITION or soft delete) -- Tests use `assertMemoryLeak()`, `assertQueryNoLeakCheck()`, `execute()` for DDL +- Tests use the `assertQuery(...)` builder for SQL assertions (see "SQL test assertions" below) and `execute()` for DDL - Single INSERT for multiple rows -### Enterprise permissions & ACL (if PR introduces new SQL statements or ALTER operations) -- New ALTER TABLE operations almost always require a new enterprise permission. If the PR adds a new ALTER statement (or any new SQL statement that modifies state), flag it if there is no corresponding `SecurityContext.authorize*()` call in the execution path. -- New features in OSS should have an enterprise counterpart that wires up ACL. Check whether the PR introduces `authorize*` methods in `SecurityContext` and whether all enterprise `SecurityContext` implementations (`EntSecurityContextBase`, `AdminSecurityContext`, `AbstractReplicaSecurityContext`, and test mocks) are updated. -- New permissions must be registered in `Permission.java` (constant, name maps, and included in `TABLE_PERMISSIONS`/`ALL_PERMISSIONS` as appropriate). -- The `PermissionParser` must be able to parse GRANT/REVOKE for the new permission name — especially if the name contains SQL keywords like `ON`, `TO`, or `FROM` that could conflict with parser grammar. -- Replica security contexts must deny new write operations (`deniedOnReplica()`). +### SQL test assertions (builder API — strict, blocking) + +QuestDB has migrated SQL test assertions to the fluent `AbstractCairoTest.assertQuery(query)` builder. These rules are blocking — treat violations as **Critical** findings, not style nits. Apply them to every test line the diff **adds or modifies** (a residual pattern that the PR merely moves or reindents is not a finding; a newly written or edited one is). + +- **`assertSql(...)` has been REMOVED — there is no query-result `assertSql(...)`/`TestUtils.assertSql(...)` to fall back to.** Any new or changed test code that asserts query results with `assertSql(...)` / `TestUtils.assertSql(...)` is a Critical finding (it will not even compile against the current base class); the author must use the builder instead: + - data: `assertQuery(sql).returns(expected)` — chain `.timestamp(...)`, `.expectSize()`, `.noRandomAccess()`, `.sizeMayVary()`, `.ddl(...)`, `.mutateWith(...)`, `.withEngine(...)`, `.withContext(...)` as needed. + - plans: `assertQuery(sql).assertsPlan(plan)` / `.assertsPlanContaining(...)` / `.assertsPlanNotContaining(...)`, or fold the plan into a data assertion via `.withPlan(...)` / `.withPlanContaining(...)` / `.withPlanNotContaining(...)`. + Do **not** accept "the surrounding file already uses `assertSql`" — there is no such helper anymore, so the diff's lines must use the new API. Flag `assertPlanNoLeakCheck(...)`, `getPlan(...)`, `assertPlanDoesNotContain(...)`, and direct `TestUtils.assertSql(...)` in new/changed test code for the same reason. The one `assertSql` that legitimately survives is the live-`ServerMain` wrapper `TestServerMain.assertSql(sql, expected)`: it is a convenience for the running-server context, internally drives the builder via `returnsOnce()` (single pass, because a live server's state mutates between reads), and is NOT the banned query-result helper — do not flag it. + +- **`.returnsOnce(...)` is a correctness smell — flag every newly added use.** `returnsOnce` runs the query through a SINGLE cursor pass and deliberately SKIPS the second read, the `calculateSize()` pass, the variable-column check, and the factory-property assertions (`supportsRandomAccess`, `expectSize`) that `.returns(...)` performs. Those skipped checks catch real bugs: cursors that don't reset correctly on `toTop()`, `size()` that disagrees between passes, random-access records that return wrong values via `recordAt()`. `returnsOnce` is **only** justified when the query's output is genuinely unstable across two reads with no underlying data change — e.g. an unseeded `rnd_*` in the projection, `now()`/`sysdate()`/`systimestamp()`-style time-varying output, or inherently non-deterministic row order. For a `.returnsOnce(...)` on a deterministic query this is a Critical finding: demand `.returns(...)`. Require the author to state *why* the query is unstable; "it was simpler" is not a reason — the shortcut leaves real bugs untested. + +- **Anti-pattern: a lone `assertQuery(...)` wrapped in `assertMemoryLeak(() -> { ... })`.** The builder runs its OWN memory-leak check by default (it wraps internally unless `.noLeakCheck()` is set). When an `assertMemoryLeak(...)` lambda's only meaningful statement is a single `assertQuery(...)` chain, the outer wrapper is redundant and almost always forces a `.noLeakCheck()` on the builder — which disables the builder's leak check and replaces it with a hand-rolled one, defeating the point. Flag it: drop the `assertMemoryLeak` wrapper and the `.noLeakCheck()`, letting the builder leak-check itself. The wrapper is only legitimate when the lambda genuinely holds multiple statements (DDL + inserts + several assertions) that must share one leak-check scope; a single builder call does not. + +### Permission hooks (if PR adds an ALTER operation or other state-mutating SQL) + +This check decides on two `rg` searches in this repo — run them instead of reasoning about them. + +- **A new ALTER TABLE operation, or any new statement that mutates table state, needs a `SecurityContext.authorize*()` call on its execution path.** Cite the callsite — the `AlterOperation.apply()` dispatch for ALTER, the op or compiler class for everything else. Absence is a proven finding, not a speculative one: the evidence is the search that finds the new operation and the search that finds no `authorize*` call covering it. Classify it with the standard rubric — a state-mutating operation no security context can refuse is a privilege bypass, which the severity table already lists as Critical. +- **A new `authorize*()` method must be implemented wherever it is abstract:** `AllowAllSecurityContext` and `ReadOnlySecurityContext` (`DenyAllSecurityContext` extends the latter), plus any test `SecurityContext` implementations the compiler does not already catch. If the PR instead adds the method with a permissive `default` body, every implementation that does not override it — including enterprise ones this checkout cannot see — silently grants the permission. The interface does use `default` deliberately in places, so ask for the rationale; treat a missing one as the finding, not the `default` itself. +- **Enterprise wiring is out of scope for a review run in this repo.** `Permission.java` registration, `PermissionParser` GRANT/REVOKE parsing, `EntSecurityContextBase` / `AdminSecurityContext` implementations, and replica `deniedOnReplica()` gating all live in a separate repository and cannot be verified from this checkout. Note them once as an enterprise follow-up when the PR adds an `authorize*()` method; do not raise them as findings and do not let them affect the verdict. ### Test review -- **Coverage gaps:** For every new or changed code path, verify a corresponding test exists. If not, flag it explicitly as "missing test for X". -- **Cross-context coverage:** For every entry in the cross-context exposure list (2.5d), verify a test exercises the changed symbol from that context. Missing cross-context tests are high-priority findings. +- **Coverage gaps are impact- and proportionality-assessed:** consume the Step 2.6 map. Missing tests alone are not blocking. For every uncovered path, establish user/operator impact, change risk, existing safeguards, and the least fragile meaningful test before choosing Critical, Moderate, accepted, or exempt. Do not accept unsupported "simple" or "hard to test" claims, and do not demand a brittle/invasive test whose demonstrated cost and fragility outweigh a small residual user risk. Add every discovered path to the private map; publish only admitted gaps. +- **Execution-mode dimensions (QuestDB-specific):** where the changed code is sensitive to them, demand coverage across the modes that alter its behavior: WAL vs non-WAL tables, O3 (out-of-order) writes vs append-only, JIT-compiled vs interpreted filters, parallel vs single-threaded execution (parallel GROUP BY/filter workers), partitioned vs non-partitioned tables. A SQL-engine change tested in only one mode is a coverage gap in the others — name the untested modes. +- **Fuzz coverage:** for parser, encoder/decoder, ingestion-protocol, or O3/WAL-merge changes, search the test tree for existing fuzz tests (`rg -l Fuzz`) covering the changed surface. If one exists and was neither extended nor mentioned as run against the change, add an `UNTESTED` Step 2.6 row; classify and publish it only through the normal proportionality and admission gates. +- **Cross-context coverage:** For every entry in the cross-context exposure list (2.5d), verify a test exercises the changed symbol from that context. Record each missing cross-context test as an `UNTESTED` Step 2.6 row; classify and publish it only through the normal proportionality and admission gates. - **Error path coverage:** Are failure cases, exceptions, and edge conditions tested — not just the happy path? - **NULL tests:** Are NULL inputs, NULL columns, and NULL expression results tested? - **Boundary conditions:** Empty tables, empty partitions, single-row tables, max-value inputs, zero-length strings. @@ -427,7 +741,7 @@ loses data and never hard-fails on a transient outage. - **Resource leak tests:** Tests must use `assertMemoryLeak()` for anything that allocates native memory. - **Test quality:** Are tests actually asserting the right thing? Watch for tests that pass trivially, assert on wrong values, or test implementation details instead of behavior. - **Regression tests:** If this PR fixes a bug, is there a test that reproduces the original bug and would fail without the fix? -- Use Grep/Glob to find existing test files for the changed classes and verify they cover the new behavior. +- Use Grep and Glob to find existing test files for the changed classes and verify they cover the new behavior. ### Test code quality - **No vacuous assertions.** Every assertion must be able to fail. Flag `assertTrue(true)`, `assertFalse(false)`, `assertEquals(x, x)`, asserting a literal against the same literal, or a `@Test` body with no assertion and no `expected=`/`assertThrows`. @@ -450,34 +764,137 @@ loses data and never hard-fails on a transient outage. ## Step 4: Output -Present ONLY verified findings (false positives are excluded). Structure as: +Present only **ADMITTED** findings. Omitted candidates, disproofs, retractions, agent counts, candidate counts, and the private ledger never appear in the public review. Do not publish a hypothesis and retract it later; finish falsification first. It is valid to report no findings. The single exception is the **Adjacent findings** section below, which carries proved pre-existing bugs as issue drafts — not findings against this PR, and weightless in every gate. + +**Proportionality.** Keep the report actionable in one sitting. If a normal-sized PR yields more than about seven total findings, re-run the admission gate on every item and remove dependent, duplicate, not-attributed, and low-value prose. Removing a not-attributed item means moving it to Adjacent findings, not discarding it. Review depth is demonstrated by evidence, not report length. + + +**Every finding — at every severity — opens with three one-line summaries, before any prose:** + +- **Problem:** what is wrong. ≤ 12 words. No mechanism or fix. +- **Net impact:** supported population and magnitude. ≤ 12 words. A behavioral item with no net regression is omitted. +- **Evidence:** the decisive artifact or static proof, including the reviewed revision identity. + +Write these lines last from the completed admission form, never first from a hunch. Then give only the minimal producer → path → symptom trace, base comparison, and suggested fix. + +``` +Problem: Symbol column read twice per scanned row. +Net impact: ~2x column IO on every filtered scan. +Evidence: benchmark.sh output at abc123; base 8ms, head 16ms. + +Problem: WAL segment leaks a file descriptor on the error path. +Net impact: Ingestion stalls after ~1k failed commits. +Evidence: WalLeakTest red at abc123, green at base def456. +``` + +Structure as: + +### Severity classification (impact-first — severity is the user's consequence, not the finding's category) + +Severity is a function of **what the user loses**, not of which checklist the finding came from. Classify by the worst *user-visible* consequence on a *reachable* path. Do not classify up "to be safe": an inflated Critical costs exactly what a real one costs and teaches the author to skim the report. + +**"The user" means a QuestDB database user or a production operator** — someone running queries, ingesting data, or operating a deployment. It does **not** mean a QuestDB developer, a CI job, or the release process. A finding whose only affected population is the team — a slower build, a broken local setup, an awkward merge — is never Critical, whatever its symptom. Developer-experience problems are Moderate at most, and most are Minor. + +**The Critical test — name the symptom.** A finding is Critical only if you can complete this sentence with something a user, operator, or on-call engineer would actually observe: *"Because of this, the user sees ___."* The valid completions are: + +- **wrong or missing data** — incorrect query results, silent truncation, lost or duplicated rows, corrupted on-disk state, divergent replica, wrong materialized-view content; +- **a crash, hang, or unavailability** — panic, deadlock, livelock, unbounded loop, OOM, fd/thread/connection exhaustion, or a leak that grows without bound under a repeatable operation; +- **a security or ACL failure** — privilege bypass, permission not enforced, credential or cross-tenant data exposure; +- **a broken or misleading failure mode** — an operation that fails with no error or the wrong error, an error message the user cannot act on, an exception swallowed so failure looks like success, a fault lost or unlogged such that an incident cannot be diagnosed; +- **a compatibility break** — on-disk format, wire protocol, public/SQL/JNI API, or config semantics changed so existing clients, existing data, or a rolling upgrade break; +- **a performance or IO regression the user can feel** — per the magnitude rule below; +- **an admitted Critical coverage gap** — the changed path is supported and reachable, a named population can execute it, a credible regression would produce one of the material consequences above, existing controls do not contain it, and Step 2.6 shows why stable coverage is warranted. The gap is counterfactual only about whether the regression currently exists; it is not counterfactual about trigger, reachability, or impact. + +**Every completion needs a trigger.** A symptom sentence must name the concrete query shape, ingest pattern, API call, config value, or operation sequence a user/operator can run: *"user does X → sees Y"*. For a coverage gap use *"user does X; if this changed path regressed as Y, the user would see Z"*. "Could theoretically return wrong results" is not evidence. + +If a behavioral candidate cannot name and execute a supported trigger with one of the consequences above, omit it; do not preserve the mechanism as Moderate. Concrete static standards, maintainability, and coverage findings may still be Moderate or Minor when fully established directly from changed source. + +**Magnitude rule for performance and IO.** Cost blocks only when it is user-observable. Ask two questions: does the cost **scale with data** (per row, per value, per page, per partition, per scanned block), and is it on a path the user **waits for or repeats**? + +- **Critical:** per-row/per-value/per-page work on a data path; extra IO multiplied across a scan (reading a column, page, partition, or file that need not be opened); an added pass over data; O(n²) in row or partition count; an algorithmic class change on a query execution path — anything that measurably moves query latency, ingestion throughput, or disk/network volume. +- **Moderate:** a bounded, non-scaling cost off the data path — a few hundred nanoseconds during SQL compilation, one extra allocation per query (not per row), a linear scan over a small fixed set (column count, partition unit, config keys), work at startup, DDL, or metadata-change time. Worth reporting and worth fixing; not worth blocking a merge. **Sub-optimal but bounded is Moderate**, even when a better algorithm plainly exists — name the better algorithm and state the bound that makes it non-blocking. +- To file a performance finding as Critical you must state the magnitude: the multiplier and what it multiplies ("one extra 4KB page read per scanned row", "a second full pass over the partition", "O(n²) in partition count"). A Critical performance finding with no stated magnitude is mis-filed. + +**Config-divergence rule.** "The same statement is accepted under config A and rejected under config B" (a flag-dependent plan shape changing what a guard sees, a validation only some execution mode runs) is a finding in its own right — an inconsistency an operator can observe across nodes — and is classified on the consequence of the divergence itself. It does not inherit the severity of the worst case reachable through the more permissive configuration; that worst case is a separate finding that must pass the symptom test, the trigger requirement, and the base-behavior check on its own. + +**Out of scope — these are not findings.** Three classes get reported constantly and are worth nothing. Drop them before they reach the report: + +- **Merge mechanics.** The reviewed artifact is the code change, not the merge event. Submodule pin position, merge order between repos, branch existence, labels, and anything true only of the PR's in-flight state are properties of *how it lands*, not of *what it does*. A PR that bumps a vendored submodule pointer necessarily pins whatever commit it was built against — that is a property of the pointer, not a defect in the change. +- **Tautologies.** Before filing, ask: *"would this finding appear on every PR of this shape?"* If yes, it describes the workflow, not this change. A finding that can never be absent is not a defect, and reporting it teaches the author to skim the report. +- **Overridden project decisions.** When the project's own tooling explicitly permits something — a CI check that passes by design, a documented exception, a convention the PR body already names — that is a decision, not an oversight. Overriding it requires evidence the decision is *wrong*, not merely that it is permissive. "CI allows this but I would not" is not a finding. +- **Upstream submodule content.** Anything inside a submodule whose pointer move Step 2.4 classified **UPSTREAM-SYNC**. That code already landed on the submodule's default branch; this PR did not write it, did not review it, and cannot be asked to fix it. A breaking change discovered there is upstream's, released independently, and belongs in an issue against that repository — never a Critical against this PR. The one exception is an integration defect at a callsite *inside this diff*, which is filed against that callsite with its own symptom and trigger. + +**Moderate.** Admitted, attributable defects with bounded or developer-facing impact: a concrete changed-line standards violation, proved weak test, missing internal-path coverage, documentation defect, or bounded off-data-path cost. An unreachable runtime theory, unchanged residual hardening opportunity, or proposition that only supports another candidate is not Moderate; omit it. + +**Minor.** Cosmetics: member ordering, naming, formatting, comment wording, import order. + +Do not inflate and do not deflate. Filing a real user-visible defect as Moderate is a review failure; so is filing a bounded compile-time micro-cost as Critical. Where two readings are defensible, pick the one you can evidence. ### Critical -Issues that must be fixed before merge. **A newly committed compiled binary or -other build artifact (see the "Committed build artifacts" checklist) is always -Critical, no matter how legitimate it looks — native/C libraries are built from -source in CI, so a binary in the diff is never acceptable.** Each must include: -- Exact file path and line numbers (including out-of-diff files) -- Whether the finding is **in-diff** or **out-of-diff** -- Code path trace showing why the bug is real -- For out-of-diff findings: the contract from 2.5c that was violated and the callsite that triggers it -- Suggested fix +Blocking issues introduced or exposed by this PR, ordered worst user impact first. Each must include: +- The three summary lines (**Problem** / **Net impact** / **Evidence**) before anything else +- The **net determination** from 3b.16(a): population, delta vs base, magnitude/frequency, offsets, and a net of **net-negative** — a Critical that is net-neutral or net-positive is mis-filed by definition +- Exact file path and line numbers +- The **symptom sentence** with its supported trigger: "user does X → sees Y". For a coverage-gap Critical: "user does X; if this changed path regressed as Y, the user would see Z" +- For a coverage-gap Critical: the credible mutation/recurrence, existing safeguards and offsets, change-risk assessment, least-fragile stable test considered, and concrete evidence that cheaper alternatives are inadequate +- Whether the finding is **in-diff** or **out-of-diff-breakage** (an unchanged callsite this PR breaks) — both are this PR's responsibility +- Code path trace showing why the bug is real and reachable +- **Base behavior for the identical trigger** (required): executed at the merge base. If base shows the same or worse user-visible outcome, omit the candidate as not attributed to this PR. For a genuinely new surface, write `N/A — new surface` and prove that base cannot express the trigger. Base rejection is the absence of a wrong-result defect, not a worse defect outcome. +- For out-of-diff-breakage: the callsite that triggers it, plus the violated contract — cite it from 2.5c at level 2+, or state it inline at levels 0-1 where 2.5c is not built +- For performance findings: the magnitude statement (the multiplier and what it multiplies) +- Suggested fix, written to be applied in THIS PR + +Pre-existing/not-attributed observations are never Critical; a fully proved one belongs under Adjacent findings instead. ### Moderate -Issues worth addressing but not blocking. This is also where **latent invariant violations whose trigger has been shown unreachable** belong (Step 3b.12): the code is fragile and worth fixing, but no input reaches the broken state today. Each such finding must name the specific guard, catch clause, or early return that makes it unreachable, state that there is no user-visible impact today, and say what future change would make it live. - -A defect only qualifies as Critical if its trigger is **reachable**. "Rare but reachable" is Critical; "shown not to exist on any code path" is not a confirmed bug at all and belongs here instead — with the reachability analysis attached. +Non-blocking admitted issues worth fixing. Every item must still include the three summary lines and its decisive evidence. Dynamic behavioral speculation is not allowed here. ### Minor -Style nits and suggestions. +Concrete cosmetics on changed lines. Non-blocking, optional. + +### Adjacent findings (not blocking — file as GitHub issues) + +Bugs that already exist on the merge base, found in code this review visited (changed files, callers from the callsite inventory, cross-context exposures), which this PR does not introduce, break, or worsen. They are **not findings against this PR**: they never appear under Critical/Moderate/Minor, never influence the verdict, and are never proposed as changes to this PR. Discarding them instead is pure waste — the investigation is already paid for, and nobody re-finds them later. + +They are held to the same evidence bar as a published finding. An adjacent draft comes only from a candidate that reached **OMITTED pre-existing/not-attributed** with its producer, reachability, and observation proved. A candidate that ended **OMITTED false** or **OMITTED unverified** stays in the private ledger; this section is not a home for speculation that failed falsification. + +Report each as a ready-to-file issue draft, so it can move to GitHub without re-investigation: + +- **Problem:** ≤ 12 words — doubles as the issue title +- **Net impact:** ≤ 12 words — population and magnitude, or "None — " +- **Location:** file path + line numbers +- **Symptom:** what a user would observe — or "latent: no user-visible impact today", naming the guard that prevents it +- **Reachability:** the path that reaches it, or why nothing does yet +- **Suggested fix:** one or two lines +- **Severity if filed standalone:** Critical / Moderate / Minor per the rubric above + +Offer to file them; do not file anything without being asked. Their count and severity sit outside the finding-proportionality budget and outside every gate in the Summary. If one is severe enough that shipping this PR without it is genuinely unsafe — because this PR moves code onto a path where the pre-existing bug now fires — then it is not adjacent: it is out-of-diff-breakage, it belongs under Critical, and you state that argument explicitly. -### Downgraded (false positives) -Findings from the initial review that were dismissed after source code verification. For each, state: -- The original claim (one line) -- Why it was dismissed (one line, citing the specific code that disproves it) +### Coverage map +State the test-gate result and the number of **admitted** coverage gaps only. Render admitted gap rows with their recorded search and failure link. Do not expose counts for omitted candidates or private UNTESTED rows; keep the full Step 2.6 matrix private unless the user asks to see it. ### Summary -- One-line verdict: approve, request changes, or needs discussion +- **Verdict**, exactly one of: + - **approve** — no open Critical findings and the test gate passes. Moderate and Minor items may remain open; list them and approve anyway. This is the expected outcome for competent work, and withholding it when both gates pass is itself a review failure. + - **approve with comments** — both gates pass; you want specific Moderate items addressed but will not block on them. Name which ones. + - **request changes** — at least one Critical is open, or the test gate fails. + - **needs discussion** — the change requires a product, architecture, or compatibility decision a reviewer cannot make alone. +- **Correctness gate (hard rule):** the verdict cannot be "approve" while any **ADMITTED** Critical finding remains open, including an admitted Critical coverage gap. Omitted hypotheses never affect the verdict. + + Before finalizing, rerun the admission audit from evidence fields rather than from report prose: + - **falsification:** state the strongest attempted disproof for each rendered behavioral finding; + - **producer:** confirm a supported operation/version/configuration actually creates every trigger state; + - **independence:** confirm the admitting verifier did not receive the discovery narrative, severity, fix, or votes; + - **dynamic evidence:** confirm races, ordering, retries, restarts, filesystem states, and compatibility claims have an executed artifact at the reviewed revision and the same-trigger base result; + - **dependency:** remove every item whose parent premise was omitted; + - **severity:** classify only after admission. Never promote missing evidence or uncertainty to Critical. + + If any field fails, omit the candidate and rerun the verdict. If the admitted Critical list is empty and the test gate passes, approve plainly; zero findings is expected for correct changes. +- **Test gate (hard rule):** the gate fails only while an **ADMITTED Critical coverage gap** remains open. Zero test changes, a bug-fix label, or missing regression coverage triggers the Step 2.6 analysis but never automatically forces `request changes`. Moderate gaps may accompany `approve with comments`; accepted gaps do not affect the verdict. Any independently admitted functional Critical still fails the correctness gate regardless of test effort or urgency. +- State the test-gate result and admitted coverage-gap count. Do not publish total UNTESTED or omitted-candidate counts from the private map. - Highlight any regressions or tradeoffs -- State how many draft findings were verified vs dropped as false positives (e.g., "8 findings verified, 4 false positives removed") -- State the in-diff vs out-of-diff split (e.g., "5 findings in-diff, 3 findings out-of-diff"). If the diff is non-trivial and out-of-diff is zero, the cross-context pass likely underran — re-invoke Agent 9 with a wider grep before finalizing. +- Never make the verdict conditional on splitting the PR. Pre-existing and not-attributed observations never affect the verdict, whether they were omitted or delivered as adjacent issue drafts. +- Do **not** state agent counts, candidate counts, rejected/false-positive counts, or retraction history. +- State the Step 2.4 submodule provenance verdicts, one line per changed pointer (e.g., "questdb: OFF-DEFAULT — in scope; java-questdb-client: UPSTREAM-SYNC — out of scope"). If a pointer moved and no verdict is stated, the scope of the review is unknown and the report is incomplete. +- State only the admitted split: in-diff / out-of-diff-breakage. At levels 0-1, describe the limited callsite analysis rather than implying a clean bill of health. +- State the severity distribution. If the report is long or severity-heavy, re-run admission; do not compensate by preserving weak items at a lower severity. From 29ae3a0dcebe2d3545c5c8fb823e0be6e4dfc0c2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 19:12:16 +0100 Subject: [PATCH 102/192] fix Java 8 build --- .../test/java/io/questdb/client/test/QuestDBBuilderTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 b880449a8..f2b2140ee 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -33,6 +33,7 @@ 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; @@ -367,7 +368,7 @@ private static void assertAuthorizationHeaders( Assert.assertNotNull("timed out waiting for an Authorization header", header); Assert.assertTrue("duplicate Authorization header: " + header, actual.add(header)); } - Assert.assertEquals(Set.of(expected), actual); + Assert.assertEquals(new HashSet<>(Arrays.asList(expected)), actual); } private static void assertTokenProviderAuthRejected(String config, HttpTokenProvider provider) { From 9947e6805c31b9e98d032983ceec86894b434b30 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 21:48:34 +0100 Subject: [PATCH 103/192] Report a credential outage from the orphan drainer An orphan BackgroundDrainer rode out a credential outage in silence and named the wrong condition when it did log. An operator whose refresh token was revoked, or whose IdP went down, while un-drained orphan slots existed saw no SenderError at all and one throttled WARN claiming the cluster was unreachable. Rows piled up in store-and-forward until backpressure resurfaced the fault as a disk-sizing problem. The foreground sender already reports exactly this fault; only the drainer did not. Two independent mechanisms produced that silence, and both are fixed. run() now builds a SenderErrorDispatcher over the drainer's existing errorSink and hands it to every CursorWebSocketSendLoop it starts, so the loop's own "credential-unavailable" report reaches the sink instead of a null dispatcher. The dispatcher is built only when a sink is installed, once per run so mid-drain loop recycles do not churn a thread, and closed by the finally after loop.close(). It drops TERMINAL on the way through: an ORPHAN loop latches a terminal to hand the slot back to the drainer, which then either rides the fault out or quarantines and reports the abandonment itself, so forwarding it would announce a dead producer for a rotating credential the next sweep accepts and would double-report every quarantine. connectWithDurableAckRetry() gains a QwpCredentialUnavailableException arm in its throttled WARN chain, beside the QwpVersionMismatchException case. That exception extends LineSenderException, so it matches none of the typed catches and lands in the generic transport arm, whose message sent operators after a network fault that does not exist. The initial connect has no loop yet, so the log is the only diagnostic that path produces. BackgroundDrainerCredentialOutageReportTest covers both halves over a real wire, and both tests fail without the fix with the original symptoms. Co-Authored-By: Claude Opus 5 (1M context) --- .../qwp/client/QwpWebSocketSender.java | 13 +- .../client/sf/cursor/BackgroundDrainer.java | 84 +++- ...oundDrainerCredentialOutageReportTest.java | 434 ++++++++++++++++++ 3 files changed, 522 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java 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 02fe34eb3..7d21ef122 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 @@ -2682,12 +2682,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) { 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 359872d65..b366f8141 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; @@ -137,10 +138,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; @@ -521,6 +525,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 @@ -544,6 +553,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()); @@ -691,6 +712,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 @@ -834,6 +861,31 @@ public void run() { // 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 @@ -856,6 +908,17 @@ 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()) { @@ -1020,6 +1083,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/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..e6585c6f6 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java @@ -0,0 +1,434 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +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 = 16384L; + private static final long SF_MAX_TOTAL_BYTES = 1L << 20; + private static final String TABLE = "trades"; + + private String slotPath; + + @Before + public void setUp() { + slotPath = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-drainer-credential-" + System.nanoTime()).toString(); + assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT)); + } + + @After + public void tearDown() { + rmDirRec(slotPath); + } + + @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 rmDirRec(String dir) { + if (dir == null || !Files.exists(dir)) return; + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + String child = dir + "/" + name; + if (!Files.remove(child)) rmDirRec(child); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(dir); + } + + 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; + } + } +} From 3223cc69dc553d489e131a118be37a30fb61bf42 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 21:57:43 +0100 Subject: [PATCH 104/192] Cover token providers on SF pooled senders Pooled WebSocket + store-and-forward + OIDC is the configuration this client is built for, and no test exercised it. SenderPool applies the token provider to a managed-slot delegate on two legs. Only the !storeAndForward leg had coverage, through QuestDBBuilderTest, which configures no sf_dir; searching the suite for a test naming both httpTokenProvider and sf_dir returned nothing. The SF leg builds its delegate through the slot-id, orphan-exclusion and recovery-mode chain and applies the provider at the end of it, for ordinary and recovery delegates alike. Unwired, every SF pooled sender's upgrade would go out with no Authorization header, take a 401 and hand the rows to store-and-forward, so an operator would learn of it through ring backpressure or a quarantined slot rather than at connect time. The recovery leg is worse: a recovery delegate replays the previous run's data, so an unauthenticated build quarantines the slot and reports DATA_LOSS for rows that were replayable. SenderPoolSfTokenProviderTest covers both legs black-box through QuestDB.connect(cfg, provider) against a real TestWebSocketServer, asserting the Authorization header the server received. The ordinary leg prewarms two SF slots and pins one token pull per sender. The recovery leg drives a genuine two-phase replay -- rows written against a never-acking server, then a new pool over the same sf_dir with no prewarm, so the recovery delegate's connect is the only one the server can see -- and also asserts the recovered frames reach the new server, which is what ties the observed header to recovery. Both tests fail when the SF leg's applyTokenProvider call is removed. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/SenderPoolSfTokenProviderTest.java | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java new file mode 100644 index 000000000..d769214aa --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java @@ -0,0 +1,307 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.HttpTokenProvider; +import io.questdb.client.QuestDB; +import io.questdb.client.Sender; +import io.questdb.client.std.Files; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Token-provider wiring for POOLED senders that also run store-and-forward — + * pooled WebSocket + SF + OIDC, the configuration this client is built for and + * the one combination no test covered. + *

    + * {@code SenderPool.buildManagedSlotSender} has two legs. The + * {@code !storeAndForward} leg applies the provider inline and is exercised by + * {@code QuestDBBuilderTest#testConnectTokenProviderSuppliesBothPoolsAndPoolGrowth}, + * which configures no {@code sf_dir}. The SF leg builds the delegate through the + * slot-id / orphan-exclusion / recovery-mode chain and applies the provider at the + * end of it, for both ordinary and recovery delegates — and nothing asserted either. + *

    + * What an unwired provider costs is not a missing header in isolation. Every SF + * pooled sender's upgrade would go out unauthenticated, take a 401, and hand the + * rows to store-and-forward; the operator would not learn at connect time but much + * later, through ring backpressure or a quarantined slot. On the recovery leg it is + * worse: a recovery delegate drains the PREVIOUS run's data, so an unauthenticated + * build quarantines the slot and reports {@code DATA_LOSS} for rows that were + * replayable all along. + *

    + * Both tests are black-box through the public facade — {@code QuestDB.connect(cfg, + * provider)} against a real {@link TestWebSocketServer} — and assert on the + * Authorization header the server actually received, so they hold for any wiring + * that gets the credential onto the wire. + */ +public class SenderPoolSfTokenProviderTest { + + private String sfDir; + + @Before + public void setUp() { + sfDir = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-sf-pool-token-" + System.nanoTime()).toString(); + } + + @After + public void tearDown() { + rmDir(sfDir); + } + + @Test + public void testSfPooledSendersCarryTheProviderToken() throws Exception { + // The SF leg of buildManagedSlotSender: every pooled SF sender must pull + // its own current token, exactly as the non-SF leg does. Two prewarmed + // slots, so this also pins that the provider is consulted per sender and + // not once for the pool. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + AtomicInteger tokenCalls = new AtomicInteger(); + HttpTokenProvider provider = () -> "ROTATING-" + tokenCalls.incrementAndGet(); + // query_pool_min=0 keeps the egress pool from connecting, so every + // captured header belongs to an SF pooled sender. + String cfg = "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + ";" + + "sender_pool_min=2;sender_pool_max=2;" + + "query_pool_min=0;query_pool_max=1;"; + + try (QuestDB db = QuestDB.connect(cfg, provider)) { + assertAuthorizationHeaders(server, "Bearer ROTATING-1", "Bearer ROTATING-2"); + // The senders are genuinely usable on those credentials, not + // merely upgraded: borrow both slots and ship a row through each. + try (Sender s1 = db.borrowSender(); Sender s2 = db.borrowSender()) { + s1.table("pooled").longColumn("v", 1).atNow(); + s1.flush(); + s2.table("pooled").longColumn("v", 2).atNow(); + s2.flush(); + } + Assert.assertTrue("both pooled SF senders must reach the server", + awaitAtLeast(handler.frames, 2, 10_000)); + } + Assert.assertEquals("one token pull per pooled SF sender", 2, tokenCalls.get()); + } + }); + } + + @Test + public void testSfStartupRecoveryDelegateCarriesTheProviderToken() throws Exception { + // The forRecovery leg of buildManagedSlotSender. A recovery delegate replays + // the user's own data from a previous run, so an unauthenticated build does + // not merely fail to connect: it quarantines the slot and reports DATA_LOSS + // for rows that were replayable. + TestUtils.assertMemoryLeak(() -> { + // Phase 1 -- a server that never acks, so three frames stay unacked on + // disk under default-0 after the pool closes. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + silent.getPort() + ";sf_dir=" + sfDir + ";" + + "sender_pool_min=1;sender_pool_max=1;" + + "query_pool_min=0;query_pool_max=1;" + + "close_flush_timeout_millis=500;"; + try (QuestDB db = QuestDB.connect(cfg, () -> "PHASE1-TOKEN")) { + try (Sender s = db.borrowSender()) { + for (int i = 0; i < 3; i++) { + s.table("recover").longColumn("v", i).atNow(); + s.flush(); + } + } + } + } + Assert.assertTrue("unacked data must persist on disk for recovery to have work", + hasSegmentFile(sfDir + "/default-0")); + + // Phase 2 -- an ack-ing server and a brand-new pool over the same sf_dir. + // sender_pool_min=0 prewarms nothing, so the ONLY connect this server can + // see is the startup-recovery delegate's. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + + AtomicInteger tokenCalls = new AtomicInteger(); + HttpTokenProvider provider = () -> { + tokenCalls.incrementAndGet(); + return "RECOVERY-TOKEN"; + }; + String cfg = "ws::addr=localhost:" + ack.getPort() + ";sf_dir=" + sfDir + ";" + + "sender_pool_min=0;sender_pool_max=1;" + + "query_pool_min=0;query_pool_max=1;"; + + try (QuestDB db = QuestDB.connect(cfg, provider)) { + Assert.assertNotNull(db); + String header = ack.pollAuthorizationHeader(10, TimeUnit.SECONDS); + Assert.assertNotNull("the recovery delegate must connect", header); + Assert.assertEquals( + "a recovery delegate must present the provider's credential -- " + + "without it the replay is rejected and the slot is quarantined, " + + "reporting DATA_LOSS for replayable rows", + "Bearer RECOVERY-TOKEN", header); + // Tie that header to recovery rather than to any other connect: + // the previous run's frames actually reach the new server. + Assert.assertTrue("the recovered frames must be replayed", + awaitAtLeast(handler.frames, 1, 10_000)); + } + Assert.assertTrue("the recovery delegate must consult the provider", + tokenCalls.get() >= 1); + } + }); + } + + 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(10, TimeUnit.SECONDS); + Assert.assertNotNull("timed out waiting for an Authorization header", header); + // The server records "" for an upgrade that carried no Authorization + // header at all -- the exact shape of an unwired provider. Name it, + // rather than letting two of them collide as a "duplicate". + Assert.assertFalse("an SF pooled sender upgraded with NO Authorization header", + header.isEmpty()); + Assert.assertTrue("duplicate Authorization header: " + header, actual.add(header)); + } + Set want = new HashSet<>(); + for (int i = 0; i < expected.length; i++) { + want.add(expected[i]); + } + Assert.assertEquals(want, actual); + } + + private static boolean awaitAtLeast(AtomicInteger counter, int target, long timeoutMillis) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (counter.get() >= target) { + return true; + } + Thread.sleep(10); + } + return counter.get() >= target; + } + + private static boolean hasSegmentFile(String slotPath) { + if (!Files.exists(slotPath)) { + return false; + } + long find = Files.findFirst(slotPath); + if (find <= 0) { + return false; + } + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + rc = Files.findNext(find); + if (name != null && name.endsWith(".sfa")) { + return true; + } + } + } finally { + Files.findClose(find); + } + return false; + } + + private static void rmDir(String dir) { + if (dir == null || !Files.exists(dir)) { + return; + } + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + String child = dir + "/" + name; + if (!Files.remove(child)) { + rmDir(child); + } + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(dir); + } + + private static final class CountingAckHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicInteger frames = new AtomicInteger(); + private final Map seqByClient = + new ConcurrentHashMap<>(); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + frames.incrementAndGet(); + AtomicLong seq = seqByClient.computeIfAbsent(client, c -> new AtomicLong(0)); + try { + client.sendBinary(buildAck(seq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + 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 final class SilentHandler implements TestWebSocketServer.WebSocketServerHandler { + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + // No ack -- the frames stay unacked on disk for phase 2 to recover. + } + } +} From efa5d9049857bfc1b965fa04296119c6009e8fc1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 22:10:34 +0100 Subject: [PATCH 105/192] Cover the fixed-vs-rotating credential tag 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 the orphan drainer's terminal policy decides on it whether a 401 during a drain may quarantine the slot. Nothing connected the builder half to the drainer half -- the only test references to fixedAuthHeader and hasDynamicCredential were two test-double overrides in the drainer tests. 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. isCredentialDynamic() exposes the tag on a built sender, and WebSocketTokenProviderTest asserts it for httpToken, httpUsernamePassword, httpTokenProvider and no credential at all. Each case pins the Authorization header the server actually received alongside the tag, since a tag asserted alone would still pass if the credential reached the wire by another route, and reads the tag both directly and through the background reconnect factory an orphan drainer is handed -- the value BackgroundDrainer actually consumes. Mutating the builder in either direction fails the new test, and only the new test: with a fixed token mis-tagged as rotating the other eleven tests in the suite stay green, because the header is still correct and nothing else looked at the tag. Co-Authored-By: Claude Opus 5 (1M context) --- .../qwp/client/QwpWebSocketSender.java | 17 +++++ .../client/WebSocketTokenProviderTest.java | 67 +++++++++++++++++++ 2 files changed, 84 insertions(+) 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 7d21ef122..bdbffe4fa 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 @@ -2008,6 +2008,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 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 index a9e6b4211..e4fb3701a 100644 --- 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 @@ -27,6 +27,7 @@ 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 org.junit.Assert; import org.junit.Test; @@ -42,6 +43,7 @@ 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; @@ -58,6 +60,46 @@ */ public class WebSocketTokenProviderTest { + @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. + 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(() -> { @@ -650,4 +692,29 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat } } } + + 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()); + } + } } From 54a364ec37eca3e95aed9300269653634c0f6e94 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 22:27:25 +0100 Subject: [PATCH 106/192] Restore the exported API signatures this branch broke Three exported, javadoc-published packages lost public descriptors that existing callers link against. There is no internal marker, no OSGi manifest and no japicmp gate to catch it, so a client that upgrades the jar without recompiling fails with NoSuchMethodError, and an external Response implementation with AbstractMethodError. No affected caller exists in this repo, the OSS repo or questdb-ent, but the branch already applies the right pattern elsewhere: the single-host createLineSender overload was preserved as a delegate, and nine of eleven connect overloads kept String. Response.recv(int) becomes a default method delegating to recv(). An implementation written before the overload existed keeps compiling and linking, and behaves exactly as it did. Both implementations in this library override it, so nothing internal changes. QwpWebSocketSender regains the two connect(..., String, ...) descriptors that became Supplier. They delegate through fixedAuthHeader, which also restores the CONSTANT-credential tag the old signature implied -- routing a constant header through a bare lambda would make the drainer read it as rotating. The supplier-backed forms are renamed connectWithCredentialSupplier rather than left as overloads: a String and a Supplier parameter of equal arity make a bare null credential ambiguous, since neither type is more specific, so keeping both under one name would trade a link error for a compile error. AbstractLineHttpSender regains the multi-host createLineSender without HttpTokenProvider, delegating with a null provider exactly as the single-host overload does. Verified with a caller written against the pre-branch signatures and compiled only against the published classes: against the unfixed classes it reproduces all four breaks, and against these it compiles clean, bare-null credential included. Folding the supplier form back into connect fails that same caller with "reference to connect is ambiguous", which is what the distinct name buys. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/io/questdb/client/Sender.java | 2 +- .../client/cutlass/http/client/Response.java | 13 +- .../line/http/AbstractLineHttpSender.java | 28 +++++ .../qwp/client/QwpWebSocketSender.java | 112 +++++++++++++++++- 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 69985980b..4b980eb86 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1691,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, 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 02c305051..324a80c52 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 @@ -39,9 +39,20 @@ public interface Response { * 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 */ - Fragment recv(int timeout); + default Fragment recv(int timeout) { + return recv(); + } } 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 4e5208dfa..6cd92dc6f 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 @@ -237,6 +237,34 @@ public static AbstractLineHttpSender createLineSender( ); } + /** + * 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, + 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 + ) { + 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, 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 bdbffe4fa..cb75020d5 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 @@ -709,7 +709,7 @@ public static QwpWebSocketSender connect( long durableAckKeepaliveIntervalMillis, long authTimeoutMs ) { - return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, + return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), requestDurableAck, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, @@ -719,12 +719,61 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), 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, @@ -746,7 +795,7 @@ public static QwpWebSocketSender connect( SenderConnectionListener connectionListener, int connectionListenerInboxCapacity ) { - return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, + return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeaderSupplier, requestDurableAck, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, @@ -759,12 +808,65 @@ 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, From 7681b96a5ce585f1bad2a312b11ddf6552aa4b01 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 22:35:43 +0100 Subject: [PATCH 107/192] Stop a device grant inheriting the previous user's refresh token storeTokens() kept the current refresh token whenever a response omitted one, for both refresh grants and fresh device grants. An omission does not mean the same thing for the two. For a refresh response it means the same authorization continues, and RFC 6749 6 makes the field optional, so keeping the token is right -- 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 one 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 says the identity changed. The persisted case is worse: with the refresh token unchanged, persistIfRotated() sees no rotation and skips the save, so A's whole entry survives on disk and the next process start adopts it. The omission policy is now grant-specific. A device grant that returns no refresh token clears it, so getToken() asks for an interactive sign-in rather than silently signing in as someone else. The comment in adopt() that claimed to mirror storeTokens() now names the refresh branch it actually mirrors. adopt() itself is unchanged and should be: a stored entry is the same authorization read back, never a new one. testDeviceGrantWithoutRefreshTokenClearsPreviousUsersRefreshToken covers the A-to-B sequence end to end, including persistence and a restart over the same store. A's refresh fails on a 503 rather than a revocation, so the token stays live and any later use of it really does resume A -- without the fix the test reports getToken() serving ACCESS-A2 after two refresh calls. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 27 +++++-- .../auth/OidcDeviceAuthPersistenceTest.java | 79 +++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) 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 index bd485390e..8eb239146 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1154,7 +1154,9 @@ private boolean adopt(PersistedToken token) { } accessToken = token.getAccessToken(); idToken = token.getIdToken(); - // keep the current refresh token when the file carries none, mirroring storeTokens(). A file with a + // 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 @@ -1362,7 +1364,7 @@ private int pollOnce(String deviceCodeEncoded) { // is not trusted (the non-2xx is classified below instead) if (isHttpStatusSuccess()) { if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { - storeTokens(tokenParser); + storeTokens(tokenParser, false); return POLL_SUCCESS; } // a 2xx with neither a token nor an OAuth error is a definitive but malformed answer @@ -1571,7 +1573,11 @@ private PersistedToken snapshot() { return new PersistedToken(accessToken, idToken, refreshToken, expiresAtMillis, tokenTtlMillis); } - private void storeTokens(TokenResponseParser parser) { + /** + * @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 @@ -1590,9 +1596,20 @@ private void storeTokens(TokenResponseParser parser) { // 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(); - // a refresh response usually omits a new refresh token; keep the current one in that case + // 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 @@ -1651,7 +1668,7 @@ && isHttpStatusSuccess() && tokenParser.error.length() == 0; if (hasRequiredToken) { try { - storeTokens(tokenParser); + 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 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 index 5be14d74f..60aa86f20 100644 --- 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 @@ -222,6 +222,85 @@ public void testClearCacheDoesNotReloadStaleEntry() throws Exception { }); } + @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 From 6246c3f486ed4ff10903226321d2641c02a47811 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:00:20 +0100 Subject: [PATCH 108/192] Make the token store's lock waits interruptible QWP close() cannot reach a credential pull through closeTraffic(): the pull is caller code owning no socket. Its only lever is an interrupt, sent by ConnectCancellation.cancel() on the foreground path and by BackgroundDrainerPool's shutdownNow() on the orphan-drainer path. Whether that lever accomplishes anything depends on what the pull is blocked in, and the built-in path honoured neither signal: the in-process lock was taken with lock(), which no interrupt breaks, and the lock file was polled through Os.sleep, which catches InterruptedException, keeps sleeping to its own deadline and never re-asserts the flag. The lock-acquire budget caps at 30s, the same as close()'s shutdown budget. A sender closing while another same-identity instance held the lock therefore 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. On the drainer path close() abandoned the drainer still holding the orphan slot's lock, which leaves the slot adoptable by nobody. inLock now takes the process lock interruptibly, polls the lock file with Thread.sleep, and checks for an interrupt before running the critical section - that section is a fresh HTTP round trip, exactly the work a cancellation is trying to stop. An interrupt carried on entry is preserved and aborts before any lock file is touched; one arriving during the wait is consumed by acting on it, so it cannot go on to break the teardown it was sent to enable. TokenStore.inLock documents the requirement, since it is a public extension point. Every existing test blocks the pull in an interruptible test double, so the shipped path went unchecked. The new tests block it in a real OidcDeviceAuth over a real FileTokenStore whose lock a peer holds. Two pin the mechanism directly; two drive it through a Sender, one on a foreground reconnect and one on an orphan drainer's initial connect, asserting the drainer's pull really happens on a drainer thread so the test cannot pass on a drainer that never started. Without the fix the first two leave their waiter running out the 30s budget, the foreground test fails with "close() timed out after 30000ms awaiting shutdown", and the orphan test takes 32.6s against a 15s ceiling. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 53 ++- .../client/cutlass/auth/TokenStore.java | 10 +- .../test/cutlass/auth/FileTokenStoreTest.java | 110 ++++++ .../WebSocketCredentialCancellationTest.java | 364 ++++++++++++++++++ 4 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketCredentialCancellationTest.java 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 index bd62b5b1c..9a482d649 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -285,32 +285,59 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // subject to the file lock's degrade. ReentrantLock is safe even though inLock's contract forbids // nesting - a mistaken re-entry cannot self-deadlock. final ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(key.hash(), k -> new ReentrantLock()); - processLock.lock(); + // 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.lockInterruptibly(); + } catch (InterruptedException e) { + // Interrupted WAITING for the process lock: a live cancellation, acted on by abandoning the + // refresh. Not re-asserted - the signal has been consumed by doing what it asked. The caller + // learns through the false return (OidcDeviceAuth turns it into a credential failure), while + // leaving the flag set would break every later blocking call on this thread, including the + // teardown the interrupt was sent to enable. + return false; + } + try { + // An interrupt CARRIED ON ENTRY is the caller's own state, not a signal aimed at this wait: + // preserve it and abort before touching any lock file. The previous code instead cleared it to + // push the FileChannel I/O through (a set flag turns that into ClosedByInterruptException) and + // ran the refresh anyway; acquiring a lock for a critical section we should not start only + // delays the caller and risks stranding a lock file for its whole staleness window. + 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; - // acquireLock does FileChannel I/O, so shield it from a carried interrupt flag exactly as load() - // does - but shield ONLY the lock bookkeeping, never action.run(). The critical section is the - // caller's own token refresh, and an interrupt is precisely the lever close() uses to break it. - boolean wasInterrupted = Thread.interrupted(); + // set when an interrupt arrives while we poll for the cross-process lock; see acquireLock + boolean cancelled = false; try { ensureDirectory(); 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 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. nonce = null; - } finally { - if (wasInterrupted) { - Thread.currentThread().interrupt(); - } } 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()) { + return false; + } return action.run(); } finally { if (nonce != null) { @@ -776,7 +803,7 @@ private static void writeNewFile(Path file, byte[] content, FileAttribute... } } - private String acquireLock(Path lock) { + 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 @@ -798,7 +825,11 @@ private String acquireLock(Path lock) { if (System.currentTimeMillis() >= deadline) { return null; // give up and run without the lock rather than stall a sign-in } - Os.sleep(LOCK_POLL_SLICE_MILLIS); + // 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 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 index e0c62b8e8..a6958d92e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -70,10 +70,18 @@ public interface TokenStore { * {@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. + *

    + * An implementation that waits for its lock must make that wait INTERRUPTIBLE and, on an interrupt, + * return {@code false} without running {@code action}. 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. The + * {@code false} return reads as "no refresh happened", which {@code OidcDeviceAuth} already handles. * * @param key the identity to lock * @param action the critical section; its boolean result is returned unchanged - * @return whatever {@code action} returned + * @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(); 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 index b31bc30a4..cfa0f2b48 100644 --- 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 @@ -45,7 +45,9 @@ import java.nio.file.attribute.FileTime; import java.nio.file.attribute.PosixFilePermissions; import java.util.Arrays; +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; @@ -540,6 +542,114 @@ public void testHashMatchesFrozenCrossLanguageContract() throws Exception { }); } + @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(); + Files.createDirectories(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<>(); + AtomicBoolean flagLeftSet = new AtomicBoolean(); + CountDownLatch entered = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + entered.countDown(); + result.set(store.inLock(key, () -> { + ran.set(true); + return true; + })); + flagLeftSet.set(Thread.currentThread().isInterrupted()); + }, "file-lock-waiter"); + waiter.setDaemon(true); + waiter.start(); + Assert.assertTrue(entered.await(5, TimeUnit.SECONDS)); + Thread.sleep(200); // let it settle into the poll loop + + long start = System.currentTimeMillis(); + waiter.interrupt(); + waiter.join(10_000); + long elapsed = System.currentTimeMillis() - start; + + 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()); + Assert.assertFalse("an interrupt that arrived during the wait is consumed by acting on it, so it " + + "cannot go on to break the teardown it was sent to enable", 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(); + Files.createDirectories(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<>(); + CountDownLatch entered = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + entered.countDown(); + result.set(waiterStore.inLock(key, () -> { + ran.set(true); + return true; + })); + }, "process-lock-waiter"); + waiter.setDaemon(true); + waiter.start(); + Assert.assertTrue(entered.await(5, TimeUnit.SECONDS)); + Thread.sleep(200); // let it settle onto the process lock + + long start = System.currentTimeMillis(); + waiter.interrupt(); + waiter.join(10_000); + long elapsed = System.currentTimeMillis() - start; + + 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()); + + release.countDown(); + holder.join(10_000); + Assert.assertFalse("the holder must finish its critical section", holder.isAlive()); + }); + } + @Test public void testInLockDegradesWhenDirectoryUnusable() throws Exception { assertMemoryLeak(() -> { 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..fa4d552e3 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketCredentialCancellationTest.java @@ -0,0 +1,364 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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.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"; + + static { + System.setProperty("questdb.client.oidc.open.browser", "false"); + } + + @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(); + } + } +} From 97fa2877fbcbf1273fcb829787807c712a202726 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:15:17 +0100 Subject: [PATCH 109/192] Reject every overflowing chunk size, not just negative ones The chunk-size guard rejected only a negative parse result, which is one of three ways an unchecked val << 4 accumulation can wrap, and the least harmful. A negative size matches neither the data branch nor the terminator, so the state machine spins - visible, if unpleasant. The other two report success with the wrong bytes: 10000000000000000 wraps to ZERO and reads as the terminal chunk, so the caller gets a complete-looking body that is actually truncated and the connection's framing is lost for the next keep-alive response. Truncated JSON parses. 10000000000000001 wraps to 1, framing one small data chunk and mis-reading everything after it. The size line is chosen by the server, which for an OIDC discovery or token response is untrusted. Numbers gains parseHexLongChecked, which accumulates with an overflow test before each shift - after it the high bits are already gone - and accepts only [0, Long.MAX_VALUE]. AbstractChunkedResponse uses it, and the negative-only guard goes with it, now unreachable. parseHexLong itself is deliberately left alone rather than tightened in place. Reading a 16-digit sequence as a two's-complement 64-bit word is a legitimate use of it, and "accept at most Long.MAX_VALUE" contradicts that, so the two cannot be one method; Numbers is also exported API. The chunk parser was its only caller in this repo, so nothing else keeps the wrapping behaviour by accident, and its javadoc now points at the checked variant. The single overflow test becomes three, one per residue: they fail in different ways and a shared test aborts at the first. The zero case needs its own wire - a trailing byte holds the read gate shut and makes the pre-fix parser stall rather than complete, so the failure showed up as a 30s timeout instead of the truncation itself. With a proper CRLF it fails in 0.3s with "got a terminal chunk (a truncated body reported as complete)". testParseHexLongChecked covers the boundary, leading zeros, the range form and all four rejections, and pins that parseHexLong still returns -1 for ffffffffffffffff so the split cannot drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/client/AbstractChunkedResponse.java | 22 +++---- .../java/io/questdb/client/std/Numbers.java | 48 +++++++++++++++ .../http/client/ChunkedResponseTest.java | 59 +++++++++++++++---- .../questdb/client/test/std/NumbersTest.java | 33 +++++++++++ 4 files changed, 140 insertions(+), 22 deletions(-) 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 6ec3a650e..8d0fcaec6 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 @@ -147,16 +147,18 @@ public Fragment recv(int timeout) { // at this stage we consumed the chunk size end (CRLF) chunkSize.of(dataLo, res + 1); try { - size = Numbers.parseHexLong(chunkSize.asAsciiCharSequence()); - if (size < 0) { - // parseHexLong accumulates val << 4 with no overflow check, so a chunk-size - // line of 16 or more hex digits (8000000000000000 is the smallest) wraps to a - // negative value. A negative size matches neither the "size > 0" data branch - // nor the "size == 0" terminator below, so the state machine would loop on it - // forever - and the size line is chosen by the server, which for an OIDC - // discovery or token response is untrusted. Reject it as malformed. - throw new HttpClientException("malformed chunk size"); - } + // Checked, not parseHexLong: that one accumulates val << 4 unchecked, so a + // chunk-size line of 16 or more hex digits wraps, and every residue is wrong in + // 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, so the response is truncated and the connection's framing is lost for + // the next keep-alive response. A positive residue frames a short data chunk and + // mis-reads everything after it. Rejecting only the negative case left the two + // quiet ones -- which are the dangerous ones, since they look like success. The + // size line is chosen by the server, which for an OIDC discovery or token + // response is untrusted. + size = Numbers.parseHexLongChecked(chunkSize.asAsciiCharSequence()); consumed = 0; // consume data buffer ignoring chunk size value and its furniture state = STATE_CHUNK_DATA; 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..ed2029435 100644 --- a/core/src/main/java/io/questdb/client/std/Numbers.java +++ b/core/src/main/java/io/questdb/client/std/Numbers.java @@ -359,6 +359,54 @@ public static long parseHexLong(CharSequence sequence, int lo, int hi) throws Nu return val; } + /** + * Parses a hexadecimal sequence into a NON-NEGATIVE long, rejecting anything above + * {@link Long#MAX_VALUE} instead of silently wrapping. + *

    + * {@link #parseHexLong(CharSequence, int, int)} accumulates {@code val << 4} unchecked, so it reads a + * 16-digit sequence as a two's-complement 64-bit word (a legitimate use, and why that method is left + * alone) and quietly discards the high bits of anything longer. Wrapping is indefensible wherever the + * digits are a COUNT chosen by a remote peer: an HTTP chunk size of {@code 10000000000000000} wraps to + * zero and reads as the terminal chunk, truncating the response, while other lengths wrap to short + * positive counts that mis-frame everything after them. + * + * @param sequence the characters to parse + * @return the parsed value, in {@code [0, Long.MAX_VALUE]} + * @throws NumericException if the sequence is empty, holds a non-hex character, or denotes a value above + * {@link Long#MAX_VALUE} + */ + public static long parseHexLongChecked(CharSequence sequence) throws NumericException { + return parseHexLongChecked(sequence, 0, sequence.length()); + } + + /** + * Range form of {@link #parseHexLongChecked(CharSequence)}. + * + * @param sequence the characters to parse + * @param lo inclusive start + * @param hi exclusive end + * @return the parsed value, in {@code [0, Long.MAX_VALUE]} + * @throws NumericException if the range is empty, holds a non-hex character, or denotes a value above + * {@link Long#MAX_VALUE} + */ + public static long parseHexLongChecked(CharSequence sequence, int lo, int hi) throws NumericException { + if (hi <= lo) { + throw NumericException.instance().put("empty hex string"); + } + long val = 0; + for (int i = lo; i < hi; i++) { + int digit = hexToDecimal(sequence.charAt(i)); + // Test BEFORE shifting: the shift is what loses the high bits, so afterwards there is nothing + // left to detect. val*16 + digit <= MAX_VALUE <=> val <= (MAX_VALUE - digit) >> 4, and both + // sides stay non-negative, so this cannot itself overflow. + if (val > (Long.MAX_VALUE - digit) >> 4) { + throw NumericException.instance().put("hex value exceeds Long.MAX_VALUE"); + } + val = (val << 4) + digit; + } + return val; + } + public static int parseIPv4(CharSequence sequence) throws NumericException { if (sequence == null || Chars.equalsIgnoreCase("null", sequence)) { return IPv4_NULL; 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 17c429417..c8b6d9116 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 @@ -221,19 +221,51 @@ protected int recvOrDie(long bufLo, long bufHi, int timeout) { @Test(timeout = 30_000) public void testOverflowingChunkSizeIsRejectedRatherThanSpun() { - // A chunk-size line of 16 or more hex digits overflows Numbers.parseHexLong (val << 4, unchecked) - // to a NEGATIVE size, which 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. defaultTimeout is -1 here on purpose: no deadline can rescue - // this call, so 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 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"); + } + + 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\n8000000000000000\r\nX"; + final String wire = "1\r\nA\r\n" + sizeLine + "\r\n" + tail; final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, -1) { boolean delivered; @@ -251,13 +283,16 @@ protected int recvOrDie(long bufLo, long bufHi, int timeout) { }; rsp.begin(mem, mem); Fragment first = rsp.recv(); - Assert.assertNotNull("the first chunk must still be delivered", first); + Assert.assertNotNull(what + ": the first chunk must still be delivered", first); Assert.assertEquals('A', (char) Unsafe.getUnsafe().getByte(first.lo())); try { - rsp.recv(); - Assert.fail("expected the overflowing chunk size to be rejected as malformed"); + 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(e.getMessage(), e.getMessage().contains("malformed chunk size")); + Assert.assertTrue(what + ": " + e.getMessage(), + e.getMessage().contains("malformed chunk size")); } } finally { Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT); diff --git a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java index d30ff29a2..c6ad4266b 100644 --- a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java +++ b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java @@ -270,6 +270,31 @@ public void testHexInt() { assertEquals(0xac, Numbers.parseHexInt("ac")); } + @Test + public void testParseHexLongChecked() { + // the boundary itself must be accepted... + assertEquals(Long.MAX_VALUE, Numbers.parseHexLongChecked("7fffffffffffffff")); + assertEquals(0L, Numbers.parseHexLongChecked("0")); + assertEquals(0xacL, Numbers.parseHexLongChecked("ac")); + // ...and leading zeros must not be mistaken for magnitude + assertEquals(1L, Numbers.parseHexLongChecked("000000000000000000001")); + // range form + assertEquals(0xf0L, Numbers.parseHexLongChecked("xxF0yy", 2, 4)); + + // ...while everything past it is rejected rather than wrapped. The three residues matter + // separately: unchecked accumulation turns them into a negative size, a zero (which an HTTP chunk + // parser reads as the terminal chunk, truncating the body) and a short positive count. + assertHexLongRejected("8000000000000000"); // negative residue, and the smallest overflow + assertHexLongRejected("ffffffffffffffff"); // the full 64-bit word parseHexLong returns as -1 + assertHexLongRejected("10000000000000000"); // zero residue + assertHexLongRejected("10000000000000001"); // positive residue + assertHexLongRejected(""); + + // parseHexLong is deliberately left wrapping: reading a 16-digit sequence as a two's-complement + // 64-bit word is a legitimate use, and it is exported API. + assertEquals(-1L, Numbers.parseHexLong("ffffffffffffffff")); + } + @Test public void testIntEdge() { Numbers.append(sink, Integer.MAX_VALUE); @@ -711,4 +736,12 @@ private static void assertParseLongException(String input) { } catch (NumericException ignore) { } } + + private static void assertHexLongRejected(String hex) { + try { + long parsed = Numbers.parseHexLongChecked(hex); + Assert.fail("expected [" + hex + "] to be rejected, got " + parsed); + } catch (NumericException expected) { + } + } } From 6674c6e7ff7e20e9fb2301f95488f8f2c152f7b6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:23:50 +0100 Subject: [PATCH 110/192] Tighten parseHexLong itself instead of adding a checked sibling Follow-up to the chunk-size overflow fix, which left parseHexLong wrapping and routed the chunk parser through a new parseHexLongChecked. The root method is now the checked one and the sibling is gone, so a caller cannot pick the unsafe overload by accident. parseHexLong accumulates with an overflow test before each shift - after it the high bits are already gone - and accepts only [0, Long.MAX_VALUE]. AbstractChunkedResponse calls it directly with no guard of its own, so all three overflow residues now depend solely on the root: reverting the check fails four tests rather than three, the negative-residue chunk test among them. This changes exported behaviour: parseHexLong("ffffffffffffffff") returned -1 and now throws. That is unavoidable, since reading a full-width two's-complement 64-bit word and accepting at most Long.MAX_VALUE are contradictory contracts and cannot be one method. A caller wanting wrap-around has to accumulate it itself. The javadoc records the trade, and the test pins the rejection so it reads as a deliberate contract rather than drift. Nothing in-tree relied on the wrapping. The chunk parser was the only production caller in this repo; questdb-core's Uuid parses hex through its own io.questdb.std.Numbers, a different class, and the parent repos reach the client's copy only for parseIPv4. parseHexInt shares the unchecked shape but its one caller feeds it exactly two characters for percent-decoding, which cannot overflow an int, so it is left alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/client/AbstractChunkedResponse.java | 22 ++++---- .../java/io/questdb/client/std/Numbers.java | 56 ++++++------------- .../questdb/client/test/std/NumbersTest.java | 26 ++++----- 3 files changed, 40 insertions(+), 64 deletions(-) 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 8d0fcaec6..7d7954229 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 @@ -147,18 +147,16 @@ public Fragment recv(int timeout) { // at this stage we consumed the chunk size end (CRLF) chunkSize.of(dataLo, res + 1); try { - // Checked, not parseHexLong: that one accumulates val << 4 unchecked, so a - // chunk-size line of 16 or more hex digits wraps, and every residue is wrong in - // 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, so the response is truncated and the connection's framing is lost for - // the next keep-alive response. A positive residue frames a short data chunk and - // mis-reads everything after it. Rejecting only the negative case left the two - // quiet ones -- which are the dangerous ones, since they look like success. The - // size line is chosen by the server, which for an OIDC discovery or token - // response is untrusted. - size = Numbers.parseHexLongChecked(chunkSize.asAsciiCharSequence()); + // parseHexLong rejects an overflowing size rather than wrapping it, so nothing + // is needed here beyond catching NumericException below. Each residue used to + // break framing its own way: a negative one (8000000000000000 is the smallest) + // matched neither the "size > 0" data branch nor the "size == 0" terminator + // below, so the state machine looped on it forever; zero (10000000000000000) + // read as the TERMINAL chunk, truncating the response and losing framing for + // the next keep-alive response on the connection; a positive residue framed a + // short data chunk and mis-read everything after it. The size line is chosen by + // the server, which for an OIDC discovery or token response is untrusted. + size = Numbers.parseHexLong(chunkSize.asAsciiCharSequence()); consumed = 0; // consume data buffer ignoring chunk size value and its furniture state = STATE_CHUNK_DATA; 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 ed2029435..c372a60a9 100644 --- a/core/src/main/java/io/questdb/client/std/Numbers.java +++ b/core/src/main/java/io/questdb/client/std/Numbers.java @@ -343,56 +343,34 @@ public static long parseHexLong(CharSequence sequence) throws NumericException { return parseHexLong(sequence, 0, sequence.length()); } - public static long parseHexLong(CharSequence sequence, int lo, int hi) throws NumericException { - if (hi == 0) { - throw NumericException.instance().put("empty hex string"); - } - - long val = 0; - long r; - for (int i = lo; i < hi; i++) { - int c = sequence.charAt(i); - long n = val << 4; - r = n + hexToDecimal(c); - val = r; - } - return val; - } - /** * Parses a hexadecimal sequence into a NON-NEGATIVE long, rejecting anything above - * {@link Long#MAX_VALUE} instead of silently wrapping. + * {@link Long#MAX_VALUE} rather than wrapping. *

    - * {@link #parseHexLong(CharSequence, int, int)} accumulates {@code val << 4} unchecked, so it reads a - * 16-digit sequence as a two's-complement 64-bit word (a legitimate use, and why that method is left - * alone) and quietly discards the high bits of anything longer. Wrapping is indefensible wherever the - * digits are a COUNT chosen by a remote peer: an HTTP chunk size of {@code 10000000000000000} wraps to - * zero and reads as the terminal chunk, truncating the response, while other lengths wrap to short - * positive counts that mis-frame everything after them. - * - * @param sequence the characters to parse - * @return the parsed value, in {@code [0, Long.MAX_VALUE]} - * @throws NumericException if the sequence is empty, holds a non-hex character, or denotes a value above - * {@link Long#MAX_VALUE} - */ - public static long parseHexLongChecked(CharSequence sequence) throws NumericException { - return parseHexLongChecked(sequence, 0, sequence.length()); - } - - /** - * Range form of {@link #parseHexLongChecked(CharSequence)}. + * This used to accumulate {@code val << 4} unchecked, which silently discarded the high bits of any + * sequence long enough to overflow. That is indefensible wherever the digits are a COUNT chosen by a + * remote peer, and every residue is wrong in its own way: an HTTP chunk size of + * {@code 8000000000000000} wrapped negative and hung the framing state machine, one of + * {@code 10000000000000000} wrapped to zero and read as the terminal chunk (a truncated body reported + * as complete), and longer values wrapped to short positive counts that mis-framed everything after + * them. + *

    + * The cost of the check is that a full-width 16-digit word with the high bit set -- {@code + * ffffffffffffffff}, previously read as {@code -1} -- is now rejected. Nothing in this library parsed + * one; a caller that wants two's-complement wrap-around must do its own accumulation. * * @param sequence the characters to parse * @param lo inclusive start * @param hi exclusive end * @return the parsed value, in {@code [0, Long.MAX_VALUE]} - * @throws NumericException if the range is empty, holds a non-hex character, or denotes a value above - * {@link Long#MAX_VALUE} + * @throws NumericException if the sequence is empty, holds a non-hex character, or denotes a value + * above {@link Long#MAX_VALUE} */ - public static long parseHexLongChecked(CharSequence sequence, int lo, int hi) throws NumericException { - if (hi <= lo) { + public static long parseHexLong(CharSequence sequence, int lo, int hi) throws NumericException { + if (hi == 0) { throw NumericException.instance().put("empty hex string"); } + long val = 0; for (int i = lo; i < hi; i++) { int digit = hexToDecimal(sequence.charAt(i)); diff --git a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java index c6ad4266b..9d4e171dd 100644 --- a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java +++ b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java @@ -271,28 +271,28 @@ public void testHexInt() { } @Test - public void testParseHexLongChecked() { + public void testParseHexLongRejectsOverflowRatherThanWrapping() { // the boundary itself must be accepted... - assertEquals(Long.MAX_VALUE, Numbers.parseHexLongChecked("7fffffffffffffff")); - assertEquals(0L, Numbers.parseHexLongChecked("0")); - assertEquals(0xacL, Numbers.parseHexLongChecked("ac")); + assertEquals(Long.MAX_VALUE, Numbers.parseHexLong("7fffffffffffffff")); + assertEquals(0L, Numbers.parseHexLong("0")); + assertEquals(0xacL, Numbers.parseHexLong("ac")); // ...and leading zeros must not be mistaken for magnitude - assertEquals(1L, Numbers.parseHexLongChecked("000000000000000000001")); + assertEquals(1L, Numbers.parseHexLong("000000000000000000001")); // range form - assertEquals(0xf0L, Numbers.parseHexLongChecked("xxF0yy", 2, 4)); + assertEquals(0xf0L, Numbers.parseHexLong("xxF0yy", 2, 4)); // ...while everything past it is rejected rather than wrapped. The three residues matter - // separately: unchecked accumulation turns them into a negative size, a zero (which an HTTP chunk - // parser reads as the terminal chunk, truncating the body) and a short positive count. + // separately: unchecked accumulation turned them into a negative value, a zero (which the HTTP + // chunk parser reads as the terminal chunk, truncating the body) and a short positive count. assertHexLongRejected("8000000000000000"); // negative residue, and the smallest overflow - assertHexLongRejected("ffffffffffffffff"); // the full 64-bit word parseHexLong returns as -1 assertHexLongRejected("10000000000000000"); // zero residue assertHexLongRejected("10000000000000001"); // positive residue assertHexLongRejected(""); - // parseHexLong is deliberately left wrapping: reading a 16-digit sequence as a two's-complement - // 64-bit word is a legitimate use, and it is exported API. - assertEquals(-1L, Numbers.parseHexLong("ffffffffffffffff")); + // The deliberate cost of the check: a full-width word with the high bit set used to read as -1 and + // is now rejected. Nothing in this library parsed one, and a caller wanting two's-complement + // wrap-around has to accumulate it itself. + assertHexLongRejected("ffffffffffffffff"); } @Test @@ -739,7 +739,7 @@ private static void assertParseLongException(String input) { private static void assertHexLongRejected(String hex) { try { - long parsed = Numbers.parseHexLongChecked(hex); + long parsed = Numbers.parseHexLong(hex); Assert.fail("expected [" + hex + "] to be rejected, got " + parsed); } catch (NumericException expected) { } From 67061c241b419202ac7c98a8ee54f1ef101fa55a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:39:54 +0100 Subject: [PATCH 111/192] Require a 2xx before trusting a discovery body fetchJson awaited the response headers and went straight to parsing, so both discovery paths read configuration out of a response whatever it claimed to be. A discovery document decides where the user signs in and where the long-lived refresh token is POSTed, and an error body can easily carry the keys: an error envelope, a proxy's branded page, a captive portal, a tenant-not-found stub. Black-box proofs constructed a working instance from an HTTP 500 /settings response and from an HTTP 404 .well-known response. The token and device-authorization paths already gate on status; this one did not. requireSuccessStatus now runs before parseBody. It validates the status is exactly three bare digits BEFORE echoing any of it - the header parser copies the status-line token verbatim apart from SP/CR/LF, so a non-digit byte means a malformed or hostile line that must not splice ESC or other control bytes into a message, a log or a terminal - and then requires a leading 2. A short all-digit status is malformed too and must not be read as a class by its leading digit, matching isHttpStatusSuccess elsewhere in the class. On rejection the body is drained within the usual bound so the keep-alive connection stays usable, and the connection is dropped when the drain cannot finish, mirroring readResponse on the token path. Each call site passes its own message, so a /settings failure and a .well-known failure are told apart. Three tests, one per shape. Without the gate the two HTTP-error cases reproduce the reported proofs - fromQuestDB returns a working instance - and the malformed-status case constructs one too. That last test uses a complete, otherwise-valid settings body on purpose: with a partial one it failed later on a missing key and proved nothing about the status. It also asserts the ESC byte never reaches the message. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 67 ++++++++++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 104 ++++++++++++++++++ 2 files changed, 168 insertions(+), 3 deletions(-) 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 index 8eb239146..1ccb50250 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -663,13 +663,15 @@ private static void discoverFromIdp(String issuer, ClientTlsConfiguration tlsCon 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"); + "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"); + "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) { @@ -711,7 +713,7 @@ private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) { return false; } - private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError) { + private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError, String statusError) { HttpClient client = endpoint.isTls ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) : HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); @@ -729,6 +731,14 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura 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 a wall-clock 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); @@ -932,6 +942,57 @@ private static void putNonNull(StringSink sink, CharSequence 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 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 index 7af85be7d..04b17686e 100644 --- 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 @@ -836,6 +836,110 @@ public void testDiscoveryDefaultsScopeToOpenid() throws Exception { }); } + @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); + try { + OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()).close(); + Assert.fail("a malformed status [" + statusToken + "] must not gate discovery open"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("malformed HTTP status code")); + 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); + try { + OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()); + Assert.fail("a 500 /settings body must not be trusted as OIDC configuration"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("did not return its settings")); + 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(() -> { From 823498c04628a586038b3f28507eea9ce97ae97f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:48:17 +0100 Subject: [PATCH 112/192] Stop a response-body read abort from re-sending a flush Both response-body reads in flush0 sit inside the try whose only catch treats HttpClientException as a retryable network error. Base could not throw there for a dribbling-but-progressing server, because recv() re-armed its timeout on every socket read; bounding the whole call made it reachable, and the consequences differ per branch. On the success branch a 2xx IS the commit: the server already has the rows. Draining its response body afterwards is only bookkeeping to keep the connection reusable, so an abort there re-sent a batch the server had accepted, with a retry budget that kept trying. The drain is now wrapped: on abort the connection is dropped, since unconsumed bytes would mis-frame the next response, and the flush is reported as the success it was. On the error branch the status is the verdict and the body is only detail for the message. An abort escaping into the catch reclassified a definitive 401, 403 or 405 as a transport failure, burned the whole retry budget against an endpoint that would keep refusing, and finally reported "Connection Failed: timed out" with the real status nowhere in it. throwOnHttpErrorResponse now wraps its body reads - all four branches at once - and falls back to a status-only exception. LineSenderException is a sibling of HttpClientException, not a subclass, so the intended throw passes through that catch untouched. Reaching either needs a chunked, slowly dribbled body, which QuestDB's own /write does not produce (it answers 204 non-chunked), so exposure is through intermediaries. testFlushResponseBodyDribbleAbortsOnRequestTimeout asserted that a dribbled body fails the flush, and MockOidcServer.dribble() answers 200 - so it was pinning this defect rather than guarding against it. It is reworked instead of left in place: it still proves the whole-read bound, since an unbounded read would hang to the test timeout, and now also proves the batch is sent exactly once, against a retry budget a re-send would visibly spend. A second test covers the error branch. Without the two catches the first throws the transport give-up after retrying and the second reports "Connection Failed: timed out [errno=35]" with no mention of the 401. MockOidcServer.dribble(int) lets the same dribble drive both paths. Co-Authored-By: Claude Opus 5 (1M context) --- .../line/http/AbstractLineHttpSender.java | 36 ++++++++- .../test/cutlass/auth/MockOidcServer.java | 17 +++- .../line/LineHttpSenderErrorResponseTest.java | 80 +++++++++++++++---- 3 files changed, 112 insertions(+), 21 deletions(-) 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 6cd92dc6f..188024f57 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 @@ -743,9 +743,22 @@ private void flush0(boolean closing) { // 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 wall-clock time against an untrusted identity provider). - consumeChunkedResponse(response, actualTimeoutMillis); // if any - if (keepAliveDisabled(response)) { - // Server has HTTP keep-alive disabled, and it's closing this TCP connection. + // 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) { + drained = false; + } + // Server has HTTP keep-alive disabled, and it's closing this TCP connection. + if (!drained || keepAliveDisabled(response)) { client.disconnect(); } lastFlushFailed = false; @@ -890,6 +903,23 @@ private void stampTokenIfPending() { } 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(); + throw new LineSenderException("Could not flush buffer: could not read the error response body", retryable) + .put(" [http-status=").putAsPrintable(statusCode.asAsciiCharSequence()).put(']'); + } + } + + private void throwOnHttpErrorResponse0(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable, int timeoutMillis) { CharSequence statusAscii = statusCode.asAsciiCharSequence(); if (Chars.equals("405", statusAscii)) { consumeChunkedResponse(response, timeoutMillis); 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 index 81b222a36..c995eee1c 100644 --- 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 @@ -105,7 +105,16 @@ public static MockResponse raw(String rawResponse) { } public static MockResponse dribble() { - MockResponse response = new MockResponse(200, "", true); + 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; } @@ -316,8 +325,10 @@ private static void writeResponse(OutputStream out, MockResponse response) throw // 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). - out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + // 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++) { 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 index e8a172644..49f521d9d 100644 --- 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 @@ -30,6 +30,8 @@ import org.junit.Assert; import org.junit.Test; +import java.util.concurrent.atomic.AtomicInteger; + import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; /** @@ -84,35 +86,83 @@ public void testProtocolDetectionErrorBodyControlAndBidiAreEscaped() throws Exce } @Test(timeout = 30_000) - public void testFlushResponseBodyDribbleAbortsOnRequestTimeout() throws Exception { + 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) must abort the read on the configured request timeout: the no-arg - // recv() the flush uses now 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. - try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.dribble())) { + // 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(0) // give up after the first aborted read, not retry to a deadline + .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 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 dribbled response-body read to abort the flush"); + Assert.fail("expected the 401 to surface"); } catch (LineSenderException e) { long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; - // aborted on the ~1s whole-read bound. The mock dribbles for ~10s, so the old per-read - // re-arm behavior 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("aborted too fast to be the 1s read bound: " + elapsedMillis + "ms", elapsedMillis >= 500); - Assert.assertTrue("aborted too slowly - re-armed per-read instead of bounding the whole read? " + elapsedMillis + "ms", elapsedMillis < 5_000); + 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")); + 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()); } } } From 47f02101ab1832f3ada0ac9446c9b05e2484acaa Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:56:07 +0100 Subject: [PATCH 113/192] Degrade around a throwing TokenStore instead of failing the sign-in TokenStore is a user-implemented SPI and persistence is documented best-effort, but tryRefreshCoordinated called inLock bare. A store that threw before running its action took the whole sign-in down with it and refreshed nothing, even though the client held a perfectly good refresh token. What the right degrade is depends entirely on whether the refresh already ran, which only the action can report, so the call now tracks whether it entered and completed: Threw before the action: 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. Threw after the action completed, releasing a lock or closing a handle: the refresh happened and the token is live. Report what the action returned. Re-running it is that same double-POST, and throwing tells the caller a completed sign-in failed. The action itself threw: that is the refresh's own failure, not the store's, so it propagates untouched - never swallowed, never replayed. Error is deliberately not caught; an OutOfMemoryError is not a store fault to degrade around. FileTokenStore also let unchecked exceptions escape its own lock bookkeeping - SecurityException from a SecurityManager, UnsupportedOperationException from a filesystem that cannot carry POSIX permissions. The acquire path now degrades to lock-free on those as it already did on IOException, and the release path, which runs in a finally after the critical section, absorbs them so bookkeeping cannot replace a completed result. The guard above already contains such an escape, so this is about the QUALITY of the degrade - coordination is kept for that refresh rather than lost - and about the reference implementation honouring the contract it publishes. TokenStore.inLock documents that contract, being a public extension point. Two tests cover the reachable branches; without the guard the store's exception escapes as "Runtime LOCK-DOWN" and "Runtime RELEASE-FAILED". The third branch has no test because it has no reachable trigger today: refreshUnderLock absorbs store load failures and tryRefresh catches its own. It is there so a future refresh-path exception is not silently converted into a degrade. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 18 +++++- .../client/cutlass/auth/OidcDeviceAuth.java | 41 +++++++++++- .../client/cutlass/auth/TokenStore.java | 10 +++ .../auth/OidcDeviceAuthPersistenceTest.java | 63 ++++++++++++++++++- 4 files changed, 127 insertions(+), 5 deletions(-) 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 index 9a482d649..745c27433 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -325,10 +325,17 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // 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 e) { + } 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 { @@ -349,6 +356,15 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { 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. + LOG.warn("could not release the OIDC token store lock; peers degrade to lock-free " + + "refresh until it goes stale [error={}]", e.getMessage()); } finally { if (wasInterruptedInSection) { Thread.currentThread().interrupt(); 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 index 1ccb50250..69e55d13d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1750,9 +1750,44 @@ 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 - return tokenStore.inLock(storeKey, this::refreshUnderLock); + // 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 warnPersistence(String operation, Throwable cause) { 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 index a6958d92e..585af5fa6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -71,6 +71,16 @@ public interface TokenStore { * {@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}. The wait can outlast the caller's own shutdown * budget - QWP's connect cancellation interrupts a thread stuck in a credential pull precisely so 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 index 60aa86f20..c9794c395 100644 --- 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 @@ -672,6 +672,56 @@ public void testSaveFailureThenRefreshDoesNotReplayRevokedToken() throws Excepti }); } + @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(() -> { @@ -1082,6 +1132,8 @@ private static final class FakeTokenStore implements TokenStore { PersistedToken loadReturns; PersistedToken peerInstallsOnLock; PersistedToken stored; + RuntimeException throwAfterAction; + RuntimeException throwBeforeAction; @Override public void clear(TokenStoreKey key) { @@ -1092,12 +1144,21 @@ public void clear(TokenStoreKey key) { @Override public boolean inLock(TokenStoreKey key, CriticalSection action) { locks.incrementAndGet(); + if (throwBeforeAction != null) { + throw throwBeforeAction; + } if (peerInstallsOnLock != null) { // simulate a peer process refreshing and writing a fresh entry while we hold the lock stored = peerInstallsOnLock; peerInstallsOnLock = null; } - return action.run(); + 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 From c287f899c90a9dbdf3f51b47a9fa8caaa38ed3d1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 00:10:59 +0100 Subject: [PATCH 114/192] Roll back HttpClient construction when it fails partway 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. HttpClient's base constructor takes a socket and two native buffers, then each platform subclass builds its poller, and neither step guarded the earlier ones. What makes it worth fixing is the trigger: epoll_create and kqueue fail on fd exhaustion, and the 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. The root predates this branch; OIDC discovery newly exposes it by building a client per fetch. The base constructor now stages the socket and both buffers in locals, assigns the fields only once ResponseHeaders has succeeded, and frees in reverse order under catch (Throwable). Each platform subclass wraps its poller construction and calls super.close() before rethrowing. Kqueue already guarded its own constructor this way, so this is the same pattern applied one level out. Epoll, Kqueue and FDSet each free their own allocations on failure already; what leaked was purely what the caller had taken before calling them. HttpClientConstructorLeakTest covers four failure points through assertMemoryLeak. Removing the rollback leaks 65536 bytes on the base path and 131072 on the poller path. The base case injects a negative response-buffer size and runs everywhere; the poller cases are Assume-guarded, so only the one matching the running platform executes - locally that is kqueue, leaving the epoll and FDSet cases to CI, which the class javadoc records. The pollers are failed through their facades rather than through a failing size, even though a size would need no facade: Kqueue's own failure path calls close() with its descriptor still zero, so an allocation failure there would close the test JVM's stdin. A facade returning a negative descriptor is the shape fd exhaustion actually takes and touches no real descriptors. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/http/client/HttpClient.java | 32 +++- .../cutlass/http/client/HttpClientLinux.java | 16 +- .../cutlass/http/client/HttpClientOsx.java | 15 +- .../http/client/HttpClientWindows.java | 9 +- .../client/HttpClientConstructorLeakTest.java | 174 ++++++++++++++++++ 5 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientConstructorLeakTest.java 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 4ac4eb120..c3ecac3bf 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 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..af8edbfde 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,7 +37,14 @@ public class HttpClientWindows extends HttpClient { public HttpClientWindows(HttpClientConfiguration configuration, SocketFactory socketFactory) { super(configuration, socketFactory); - this.fdSet = new FDSet(configuration.getWaitQueueCapacity()); + // See HttpClientLinux: an allocation failure here would strand the socket and native buffers the + // base constructor already took, on an object nobody can close. + try { + this.fdSet = new FDSet(configuration.getWaitQueueCapacity()); + } catch (Throwable t) { + super.close(); + throw t; + } this.sf = configuration.getSelectFacade(); } 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..41bb47075 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientConstructorLeakTest.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.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.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 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, which a large capacity overflows negative, and + // allocateMemory rejects a negative size. An allocation that simply fails is exactly the shape a + // real one takes under memory pressure. + assertMemoryLeak(() -> assertConstructionFailureLeaksNothing(new DefaultHttpClientConfiguration() { + @Override + public int getWaitQueueCapacity() { + return Integer.MAX_VALUE; + } + })); + } + + private static void assertConstructionFailureLeaksNothing(HttpClientConfiguration configuration) { + HttpClient client = null; + try { + client = HttpClientFactory.newPlainTextInstance(configuration); + Assert.fail("expected the poller's initialisation failure to abort construction"); + } 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 + } finally { + // defensive: if construction unexpectedly succeeded, do not leak it out of the test + if (client != null) { + client.close(); + } + } + } +} From fcd8859987de7d905cdf7b87e331c7211ae287ca Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 14:29:36 +0100 Subject: [PATCH 115/192] Reject a store entry carrying only a refresh token adopt() treated an absent served token as a legitimate shape and kept the file's refresh token regardless. That is right when the grant returned the other kind - storeTokens nulls only the kind the response omitted - but an entry carrying NEITHER an access token nor an id token cannot have come from this client: the device path reaches storeTokens only behind "accessToken.length() > 0 || idToken.length() > 0", the refresh path only behind a non-blank served kind, and persistIfRotated runs solely at the tail of storeTokens. Left adopted it is the cheapest credential swap there is. An attacker who can write the store directory - never needing to read the 0600 file - drops in an entry 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. adopt() now rejects the whole entry, exactly as the tampered-served- token branch beside it already does. The cost when it fires on an honest file is one interactive sign-in. The on-disk format is a frozen cross-language contract and did not forbid the shape, so a conforming Python writer could have produced one. design/oidc-token-persistence.md now states that at least one of access_token / id_token MUST be present, that a writer MUST NOT persist an entry without one, and that a reader MUST reject such an entry. Without the guard the new test fails with AssertionError: the planted refresh token must never reach the token endpoint which is the security property itself rather than a proxy for it: the planted credential goes out on the wire. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 23 ++++++++++++ .../auth/OidcDeviceAuthPersistenceTest.java | 35 +++++++++++++++++++ design/oidc-token-persistence.md | 11 ++++++ 3 files changed, 69 insertions(+) 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 index 69e55d13d..74b910168 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1196,6 +1196,29 @@ private boolean adopt(PersistedToken token) { 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", the refresh path only behind a non-blank + // served kind, and persistIfRotated runs solely at the tail of storeTokens - so every entry we + // write carries at least one token kind. 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; 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 index c9794c395..efa611256 100644 --- 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 @@ -330,6 +330,41 @@ public void save(TokenStoreKey key, PersistedToken token) { 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 testGetTokenAsFirstCallAfterRestore() throws Exception { assertMemoryLeak(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 42abb5b1d..022cf6e8f 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -398,6 +398,17 @@ serving — but it defeats *sharing*, leaving each client to re-prompt). anywhere (for example a top-level `[ {…} ]` wrapper) or a non-object root - rather than extract fields from a malformed structure. The Python client MUST do the same. + At least one of `access_token` / `id_token` MUST be present. A writer MUST NOT persist an + entry carrying only a `refresh_token`, and a reader MUST reject one - the whole entry, + refresh token included. No conforming grant produces that shape (RFC 6749 5.1 requires + `access_token` in a token response, and a client stores an entry only after a grant it could + serve), so a file in that shape was not written by a conforming client. Adopting it is a + silent credential swap: an attacker who can write the store directory - without ever reading + the 0600 file - plants an entry whose fingerprint fields are all derivable from public + config, and the reader's next silent refresh presents the attacker's refresh token and + resumes as them, with no prompt and no log line recording the change of identity. The cost + of the rule when it fires on an honest file is one interactive sign-in. + The numeric fields (`v`, `expires_at_millis`, `token_ttl_millis`) are **plain JSON integers**: an optional leading `-` followed by bare digits. A reader MUST NOT accept its own language's numeric extensions here — QuestDB's `Numbers.parseLong` would otherwise take `1_000` and `5L`, From 793db4644a9b52f53cbec5e9893090222e195ccc Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 14:29:52 +0100 Subject: [PATCH 116/192] Assert the store directory instead of assuming it Two gaps let FileTokenStore believe its documented 0700 protection applied when it did not. ensureDirectory() re-asserted permissions only on the branch where the directory already existed. Files.createDirectories is a no-op when the directory is already there, and it applies DIR_ATTRS only to directories it actually creates, so a peer that won the race between the isDirectory check and the call kept whatever permissions IT chose - and "we called createDirectories" was taken as evidence the directory was ours. That window is exactly the hostile local pre-create the method exists to defeat. restrictToOwner now runs on both paths. restrictToOwner() swallowed IOException with "the directory is not ours to inspect/chmod: keep the existing permissions". That is the one state in which the at-rest protection of the plaintext token files does not hold and another local user can create, replace or delete entries in it, and every caller was told nothing. It now propagates, and the callers degrade as each should: save() refuses to write a plaintext refresh token into a directory it cannot protect (persistIfRotated warns and carries on with the in-memory token), and inLock() runs lock-free through its existing catch. UnsupportedOperationException is still handled separately, so a non-POSIX filesystem keeps falling back to the inherited ACL. Neither change is unit-testable in process, and no test is added rather than one that would pass for the wrong reason. The race needs a peer to act between two statements with no seam to inject at. The throw needs a directory owned by a different uid: as the same user a chmod on a directory we own always succeeds, and a mode-000 parent makes isDirectory return false, so the failure comes from createDirectories instead - which testInLockDegradesWhenDirectoryUnusable already drives and which this leaves unchanged. A real test for either needs a Files facade seam the class does not have. testEnsureDirectoryTightensPreExistingDirPerms still covers the ordinary drift path. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) 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 index 745c27433..f02ed1ec2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -740,13 +740,24 @@ private static void replaceTarget(Path tmp, Path target) throws IOException { throw lastDenied; } - private static void restrictToOwner(Path directory) { - // best-effort: the at-rest protection of the plaintext token files is exactly these owner-only - // directory permissions, so re-tighten a pre-existing directory another tool/umask left loose rather - // than trust whatever it had. ensureDirectory runs this on every save and every inLock, so only chmod - // on detected drift - skip the write syscall in the common case where the permissions already match. On - // a non-POSIX filesystem (Windows) this is unsupported and falls back to the directory's existing ACL - // (owner-only hardening there, via AclFileAttributeView, is a separate follow-up) + /** + * 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 + * @throws IOException if the directory exists but its permissions cannot be read or set + */ + private static void restrictToOwner(Path directory) throws IOException { try { if (!DIR_PERMS.equals(Files.getPosixFilePermissions(directory))) { Files.setPosixFilePermissions(directory, DIR_PERMS); @@ -754,8 +765,6 @@ private static void restrictToOwner(Path directory) { } catch (UnsupportedOperationException e) { // non-POSIX FS (e.g. Windows): cannot enforce owner-only perms; keep the inherited ACL warnNoPosixPermsOnce(); - } catch (IOException ignore) { - // the directory is not ours to inspect/chmod: keep the existing permissions } } @@ -878,19 +887,22 @@ private Path createTempFile(String prefix) throws IOException { } private void ensureDirectory() throws IOException { - if (Files.isDirectory(directory)) { - // re-assert owner-only permissions on a pre-existing directory: createDirectories applies - // DIR_ATTRS only when it creates the directory, so one left world/group-accessible by another - // tool, a permissive umask, or a hostile local pre-create would otherwise expose the token files - restrictToOwner(directory); - return; - } - try { - Files.createDirectories(directory, DIR_ATTRS); - } catch (UnsupportedOperationException e) { - warnNoPosixPermsOnce(); - Files.createDirectories(directory); + 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. + restrictToOwner(directory); } private boolean isOlderThan(Path lock, long thresholdMillis) { From d3d3650bea34b1cfabdd90e7381e4af3b5aacd1c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 14:39:02 +0100 Subject: [PATCH 117/192] Keep a carried interrupt from swallowing a sign-out clearCache() reported success while leaving the plaintext refresh token on disk, whenever the calling thread merely carried an interrupt flag - the standard state of a cancelled or shutting-down thread, which is exactly where a sign-out runs. The next process start, or any peer sharing the identity, then silently resumed the old principal. ReentrantLock.lockInterruptibly() begins with Thread.interrupted(), so it throws on a FREE, UNCONTENDED lock and CLEARS the flag. inLock tested the flag only after that acquire, so a carried interrupt was misread as a live cancellation by the catch, which does not re-assert: the caller's cancellation signal was destroyed and the critical section skipped on a lock nobody held. The guard written for exactly this case, three lines below, was unreachable. clear() puts its whole body in that critical section and discards the false return, so the delete never ran and no exception or warning surfaced. inLock now tests the carried flag BEFORE the acquire and re-asserts it, which is what the existing guard intended; that guard stays, now catching only a live interrupt that lands between the acquire and it. This also stops getToken() reporting "the cached token expired and could not be refreshed" on a reachable endpoint with a free lock. clear() becomes interrupt-neutral like load() and save(), and more sharply: those abandon file I/O, this is a local delete whose whole purpose is to erase a secret, so there is nothing to abandon and "we were cancelled" is not a reason to leave a credential behind. If inLock still declines to run the action - a live cancellation, or it cannot coordinate at all - the delete runs uncoordinated rather than returning with the token on disk. inLock returns false only when the action never ran, so this cannot double-delete; losing the cross-process ordering costs at worst a peer re-persisting later, which clear() already documents as best-effort. Without the fix the new tests fail with AssertionError: clear() must erase the credential even on an interrupt-carrying thread AssertionError: a carried interrupt must survive inLock Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 87 +++++++++++++------ .../test/cutlass/auth/FileTokenStoreTest.java | 62 +++++++++++++ 2 files changed, 123 insertions(+), 26 deletions(-) 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 index f02ed1ec2..1e8416775 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -252,28 +252,52 @@ public void clear(TokenStoreKey key) { if (!Files.isDirectory(directory)) { return; // nothing is persisted yet; do not create the directory just to clear it } - // 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. - inLock(key, () -> { - try { - Files.deleteIfExists(tokenFile(key)); - } catch (IOException e) { - throw new OidcAuthException(e).put("could not remove the OIDC token store file"); + // 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(); } - // 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; - }); + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } } @Override @@ -284,6 +308,18 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // 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; + } final ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(key.hash(), k -> new ReentrantLock()); // 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 @@ -301,11 +337,10 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { return false; } try { - // An interrupt CARRIED ON ENTRY is the caller's own state, not a signal aimed at this wait: - // preserve it and abort before touching any lock file. The previous code instead cleared it to - // push the FileChannel I/O through (a set flag turns that into ClosedByInterruptException) and - // ran the refresh anyway; acquiring a lock for a critical section we should not start only - // delays the caller and risks stranding a lock file for its whole staleness window. + // 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; 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 index cfa0f2b48..ff20f58c9 100644 --- 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 @@ -176,6 +176,37 @@ public void testClearDeletesFile() throws Exception { }); } + @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(() -> { @@ -650,6 +681,37 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { }); } + @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(() -> { From 8cf04f99ae9f45dcc57fe920a260c056fb37dad6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:10:14 +0100 Subject: [PATCH 118/192] Handle a malformed response head from the provider HttpHeaderParser rejects a response head it cannot parse - a header block past its fixed 4096-byte buffer, a malformed Content-Length, a status line that is not HTTP/1.x - by throwing HttpException. That is a SIBLING of HttpClientException, not a subclass, and nothing in core/src/main caught it, so it escaped every guard the OIDC layer has. The identity provider is untrusted here, so this is a response shape it can choose at will, and an honest one behind a WAF or proxy stacking Set-Cookie/CSP headers reaches 4 KiB on its own. In postForm it missed the catch and the client.disconnect() with it, so the CACHED keep-alive client kept a half-read response for the next poll to parse as its own - precisely the corruption that catch exists to prevent, and it persists for the life of the OidcDeviceAuth instance. It also missed pollForToken's classification, aborting a whole interactive sign-in on a condition the same loop rides out when it arrives as a transport error. In fetchJson it escaped both catches, so fromQuestDB threw a type its own javadoc does not name, past every caller's catch (OidcAuthException) degrade handler. Both sites now answer it exactly as they answer an unusable response, because that is what it is: postForm drops the connection and re-reports it as the transport-class failure every caller already handles, and fetchJson folds it into the OidcAuthException it documents. The parser's message is a constant, never response bytes, so it carries no untrusted text; copying it out also detaches the thread-local flyweight HttpException.instance() returns, whose message the next HttpException on the same thread would overwrite. Without the fix both new tests fail with the defect verbatim: io.questdb.client.cutlass.http.HttpException: header is too large uncaught out of fromQuestDB and out of signIn(). The same gap exists on the pre-existing ILP path, where HttpException escapes flush0's catch (HttpClientException) at the response.await call and reclassifies a definitive status as a transport failure. That one is untouched here - it predates this branch and belongs in its own change. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 27 +++++++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 64 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) 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 index 74b910168..9aec6f52c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -27,6 +27,7 @@ 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; @@ -742,7 +743,12 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura // parseBody enforces a wall-clock 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 e) { + } 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); @@ -1487,6 +1493,25 @@ private void postForm(Endpoint endpoint, JsonParser parser) { // 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()); } } 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 index 04b17686e..88dadc29c 100644 --- 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 @@ -730,6 +730,70 @@ public void testDeviceFlowHappyPath() throws Exception { }); } + @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(() -> { From 71c04f9ce72ea3372cb600b09d83c7e7d5887a11 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:23:33 +0100 Subject: [PATCH 119/192] Bound the rotating-credential 401 ride-out An orphan drainer riding out a rotating-credential 401 could sweep forever, never escalating. 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 that to Long.MAX_VALUE nanos. The gate quarantines only when the attempt threshold AND the wall-clock dwell are both exhausted, so a saturated dwell made the second conjunct permanently false. Against a credential that is not healing - a client deleted at the IdP, a wrong audience, a revoked scope - the drainer then never wrote the .failed sentinel and never reported DATA_LOSS, so neither the SenderErrorHandler nor an operator scanning sentinels ever learned. It held the slot's lock and one worker of a FIXED-size BackgroundDrainerPool for the life of the process, starving every other orphan slot. The constant's own javadoc promised the opposite: that a credential which stays rejected "still reaches a human after both thresholds are met rather than pinning the slot and a drainer-pool worker forever". The capability-gap gate 80 lines below already survives the same saturation because it is an OR: its attempt cap fires regardless, and the comment above reconnectBudgetNanos says so. 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 the attempt ceiling is added as a CONJUNCT instead: both original thresholds still gate the quarantine, and the ceiling only stops an unsatisfiable dwell turning "ride it out" into "never escalate". It is sized far above any legitimate ride-out rather than as a second threshold. At the default reconnect_max_backoff_millis of 5s the default five-minute dwell is satisfied in roughly 60 sweeps, so 240 only bites after four times that. Without the ceiling the new test does not fail an assertion - the call never returns, and it burns its whole 60s timeout: TestTimedOutException: test timed out after 60000 milliseconds The sibling defect on the same gate is left alone: the dwell is charged wall clock from the first rejection including time when no rejection was occurring, so an unrelated outage between two 401s can satisfy it early. That one is not a regression - the merge base quarantined on the FIRST 401 - and it wants either a reset in the transient arms or a narrower javadoc, which is its own change. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 27 +++++++++++++++-- .../BackgroundDrainerDurableAckRetryTest.java | 30 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) 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 b366f8141..c9c859ee3 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 @@ -109,6 +109,23 @@ public final class BackgroundDrainer implements Runnable { * 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} sweeps, whatever the wall-clock dwell + * says. The dwell below is an AND with the attempt threshold - both must be exhausted - and it is + * derived from {@code reconnect_max_duration_millis}, which is validated only as {@code > 0} and whose + * documented way to ask for "never give up" on reconnect is {@code Long.MAX_VALUE}. {@code TimeUnit} + * saturates that to {@code Long.MAX_VALUE} nanos, so the dwell conjunct could never be satisfied 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 is an OR, so its attempt cap already survives the same saturation; this is + * the equivalent guarantee for a gate that cannot be an OR without losing its dwell floor. + *

    + * Sized far above any legitimate ride-out rather than as a second threshold: at the default + * {@code reconnect_max_backoff_millis} of 5s, the default 5-minute dwell is satisfied in roughly 60 + * sweeps, so this only bites after four times that - by which point the credential is not healing. + */ + public static final int MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING = 240; 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 @@ -389,9 +406,15 @@ public WebSocketClient connectWithDurableAckRetry() { firstDynamicCredentialAuthFailureNanos = now; } dynamicCredentialAuthElapsedNanos = now - firstDynamicCredentialAuthFailureNanos; + // The ceiling is a conjunct, not a third alternative: the ride-out still needs BOTH + // the attempt threshold and the dwell floor to quarantine, so a healing credential is + // never abandoned early. It exists only so an unsatisfiable dwell - a saturated + // reconnect_max_duration_millis, see MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING - + // cannot turn "ride it out" into "never escalate". retryDynamicCredentialAuth = - dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS - || dynamicCredentialAuthElapsedNanos < reconnectBudgetNanos; + dynamicCredentialAuthAttempts < MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING + && (dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS + || dynamicCredentialAuthElapsedNanos < reconnectBudgetNanos); } if (retryDynamicCredentialAuth) { lastErrorMessage = e.getMessage(); 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 b58f57d2d..c95f1f026 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 @@ -385,6 +385,36 @@ public void testRotatingCredentialAuthRejectionRidesOutBoundedBudget() throws Ex }); } + @Test(timeout = 60_000) + public void testRotatingCredentialAuthRideOutTerminatesOnAnUnboundedBudget() throws Exception { + assertMemoryLeak(() -> { + // 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 - could never be satisfied 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, starving every other orphan slot. Without the ceiling this call does + // not return and the test times out. + ScriptedFactory factory = ScriptedFactory + .alwaysFailing(() -> new QwpAuthFailedException(401, "127.0.0.1", 9000)) + .withDynamicCredential(); + BackgroundDrainer drainer = newDrainerWithBudgets( + factory, Long.MAX_VALUE, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS); + List captured = Collections.synchronizedList(new ArrayList()); + drainer.setErrorSink(captured::add); + + WebSocketClient out = drainer.connectWithDurableAckRetry(); + + assertNull(out); + assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome()); + assertEquals("the ceiling, not the unsatisfiable dwell, must end the ride-out", + BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING, 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 public void testReturnsClientOnSuccessFirstAttempt() throws Exception { assertMemoryLeak(() -> { From 6b7ccc4d34254f85b6459f105b73beef6da66e28 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:41:29 +0100 Subject: [PATCH 120/192] Check the store directory on the read path too adopt() rejects an entry carrying only a refresh token, which is the cheapest plant. A COMPLETE one still worked: a dummy access token, the attacker's refresh token, and an expiry already in the past takes the normal path in adopt(), the clamp leaves it expired, and the very next getToken() refreshes with their credential. Closing that needs the container checked as well as the artefact - an entry sitting in a directory another local user can write was never ours to trust, whatever it contains - and load() was the one path that never asserted the directory, which is exactly the path a restarted producer resuming from a persisted token takes. load() now calls ensureDirectory() and fails closed, returning null: the documented outcome for any unusable entry, degrading to a refresh or an interactive sign-in. The trust test is "was writable by group or other", not "was not exactly 0700". Only write permission on a directory lets another user create or replace an entry in it. The 0755 a default umask produces exposes no token - the files are 0600 - and everything in it was still put there by us, so it is tightened for defence in depth and its content stays trusted. Testing for 0700 instead would discard honest tokens and, worse, make roughly ten negative assertions in FileTokenStoreTest pass for the wrong reason, since they hand-create the directory under the umask. A distrusted entry is DELETED, not skipped. Tightening protects what we write from here on and says nothing about what was there before, so leaving the file would hand it to the next load, which now sees an owner-only directory and would trust it. Without the guard the new tests fail with AssertionError: an entry from a directory other local users could write must not be adopted expected null, but was: Residual, unchanged by this: on Windows POSIX permissions cannot be enforced, so restrictToOwner reports the directory as trusted and this check does not apply there - the AclFileAttributeView hardening the class already records as a follow-up is what would close it. A client running as root also bypasses permission checks entirely. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 71 +++++++++++++++++-- .../test/cutlass/auth/FileTokenStoreTest.java | 57 +++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) 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 index 1e8416775..b19c4bbcd 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -169,6 +169,7 @@ public final class FileTokenStore implements TokenStore { // 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 warnedUnprotectedStoreDir = new AtomicBoolean(); private final Path directory; private final long lockAcquireBudgetMillis; private final long lockStaleMillis; @@ -424,6 +425,36 @@ public PersistedToken load(TokenStoreKey key) { // 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) { + warnUnprotectedStoreDirOnce("it could not be restricted to owner-only access"); + return null; + } + 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 the entry + // rather than merely skip it: leaving it would hand the very same file to the next load, + // which now sees an owner-only directory and would trust it. + warnUnprotectedStoreDirOnce("it was writable by other local users; the entry found in it was " + + "discarded rather than trusted, and a fresh sign-in is required"); + try { + Files.deleteIfExists(tokenFile(key)); + } catch (IOException ignore) { + // best-effort: a delete failure must not turn an untrusted entry into a thrown load + } + sweepTempFiles(key.hash(), 0L); + return null; + } Path file = tokenFile(key); byte[] bytes; try { @@ -790,16 +821,29 @@ private static void replaceTarget(Path tmp, Path target) throws IOException { * 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 void restrictToOwner(Path directory) throws IOException { + private static boolean restrictToOwner(Path directory) throws IOException { try { - if (!DIR_PERMS.equals(Files.getPosixFilePermissions(directory))) { + 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)) { 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; } } @@ -839,6 +883,19 @@ private static void warnNoPosixPermsOnce() { + "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 @@ -921,7 +978,13 @@ private Path createTempFile(String prefix) throws IOException { } } - private void ensureDirectory() throws IOException { + /** + * 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); @@ -937,7 +1000,7 @@ private void ensureDirectory() throws IOException { // 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. - restrictToOwner(directory); + return restrictToOwner(directory); } private boolean isOlderThan(Path lock, long thresholdMillis) { 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 index ff20f58c9..b40070899 100644 --- 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 @@ -1011,6 +1011,63 @@ public void testLoadMissingReturnsNull() throws Exception { }); } + @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 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")); From c0b341d7b76a542e6cad97bad881d594dbd7e8bc Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:45:13 +0100 Subject: [PATCH 121/192] Retry an unparseable response head on the ILP flush response.await() hands the response head to HttpHeaderParser, which rejects one it cannot parse - a header block past its fixed 4096-byte buffer (an intermediary stacking Set-Cookie/CSP), a malformed Content-Length, a status line that is not HTTP/1.x - by throwing HttpException. That is a SIBLING of HttpClientException, not a subclass, so it escaped flush0's retry arm entirely: no retry, no address rotation, and no client.disconnect(), which is what keeps the next flush off a connection still holding a half-read response. flush() then threw a raw HttpException rather than the LineSenderException its contract promises, past every caller's catch. An unparseable head is the response being unusable, which is exactly what that arm already handles, so it now catches both. This is the pre-existing ILP twin of the OIDC gap fixed in 8cf04f99. It predates the branch, which is why it was left out of that commit. Without the fix the new test errors with the defect verbatim: io.questdb.client.cutlass.http.HttpException: header is too large I also changed the construct-time protocol-version probe and then reverted it: that loop is already wrapped in catch (Throwable), which frees the client and rethrows as "Failed to detect server line protocol version", so the site was never exposed. The test written for it passed with and without the change - a test that cannot fail - so both were dropped rather than left in. Co-Authored-By: Claude Opus 5 (1M context) --- .../line/http/AbstractLineHttpSender.java | 15 +++++++- .../line/LineHttpSenderErrorResponseTest.java | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) 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 188024f57..1a639b085 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 @@ -31,6 +31,7 @@ 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; @@ -784,8 +785,18 @@ private void flush0(boolean closing) { continue; } throwOnHttpErrorResponse(statusCode, response, false, actualTimeoutMillis); - } catch (HttpClientException e) { - // this is a network error, we can retry + } catch (HttpClientException | HttpException e) { + // this is a network error, we can retry. + // + // HttpException too: response.await() above hands the response head to HttpHeaderParser, + // which rejects one it cannot parse - a header block past its fixed 4096-byte buffer (an + // intermediary stacking Set-Cookie/CSP), a malformed Content-Length, a status line that is + // not HTTP/1.x - by throwing HttpException. That is a SIBLING of HttpClientException, not a + // subclass, so it escaped this catch and with it the retry, the address rotation and the + // client.disconnect() that keeps the next flush off a connection holding a half-read + // response. It also left flush() throwing a raw HttpException rather than the + // LineSenderException its contract promises, past every caller's catch. An unparseable head + // is the response being unusable, which is exactly what this arm already handles. lastFlushFailed = true; client.disconnect(); // forces reconnect long nowNanos = System.nanoTime(); 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 index 49f521d9d..08e4a719f 100644 --- 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 @@ -53,6 +53,44 @@ public class LineHttpSenderErrorResponseTest { // U+202E RIGHT-TO-LEFT OVERRIDE: reorders displayed text (visual spoofing) private static final char RLO = 0x202e; + @Test(timeout = 30_000) + public void testMalformedResponseHeadOnFlushIsRetriedAsATransportError() 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's retry arm and with it the retry, the address rotation and 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. + try (MockOidcServer server = new MockOidcServer((method, path, body) -> { + 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(100) // exhaust the retry budget quickly + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("an unparseable response head must fail the flush"); + } catch (LineSenderException e) { + // the documented type, reached through the retry arm; a raw HttpException here is + // the regression + Assert.assertTrue(e.getMessage(), e.getMessage().contains("Connection Failed")); + } + } + } + }); + } + @Test(timeout = 30_000) public void testProtocolDetectionErrorBodyControlAndBidiAreEscaped() throws Exception { assertMemoryLeak(() -> { From 4b7e4ed23588ecdeb0b9fadd863629f97c5dea5e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:51:24 +0100 Subject: [PATCH 122/192] Restart the rotating-401 dwell after a transient The dwell measures how long a rotating credential has been REJECTED, so time the drainer spent in an unrelated state is not part of it. The anchor was set at the first 401 and never restarted, so wall clock kept accruing across role rejects, transport outages and credential- unavailable sweeps in between. A 401, then an outage outlasting the dwell, then a sixth rejection therefore satisfied the attempt threshold and the wall-clock floor at the same moment, and quarantined the slot on a credential that had been rejected for seconds. That drops a .failed sentinel nothing in production clears, permanently abandoning replayable rows over a fault the next token pull may well have repaired - the direction the design explicitly calls the dangerous one. The transient arms already restart the capability-gap episode for the same reason ("this unrelated state breaks the consecutive run"); the anchor now restarts alongside it, in both of them. The attempt counter deliberately does NOT reset: a credential alternating rejected/unreachable must not refill it indefinitely and stall an escalation an operator needs to see. That was also the reason the anchor was left unreset, which the ceiling added in 71c04f9c now covers - the escalation is reachable regardless, so the anchor is free to measure what it claims to. Not a regression, which is why it was not folded into 71c04f9c: the merge base quarantined on the FIRST 401 unconditionally, so head is better than base for every input either way. The declaration comment claiming the anchor "never resets" is corrected. Without the fix the new test fails with AssertionError: the outage must not have satisfied the dwell [attempts=7] - quarantine on the sixth rejection, exactly as described. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 25 +++++++++++- .../BackgroundDrainerDurableAckRetryTest.java | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) 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 c9c859ee3..51773fb8f 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 @@ -346,8 +346,11 @@ public WebSocketClient connectWithDurableAckRetry() { // rejected/unreachable cannot refill it indefinitely and stall the quarantine that an operator // needs to see. int dynamicCredentialAuthAttempts = 0; - // The rotating-auth wall-clock floor is anchored at the first 401/403 and, like the attempt - // threshold, never resets during this drain. A zero value means no rejection has been observed. + // 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. The attempt threshold, unlike this, never resets during the drain. A zero value means no + // rejection has been observed. long firstDynamicCredentialAuthFailureNanos = 0L; // Wall-clock time accumulated across uninterrupted gap-to-gap // intervals of the current episode; escalates once it reaches @@ -458,6 +461,15 @@ 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 + // MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING backstops the escalation regardless. + firstDynamicCredentialAuthFailureNanos = 0L; BackgroundDrainerListener l = listener; if (l != null) { try { @@ -560,6 +572,15 @@ 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 + // MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING backstops the escalation regardless. + firstDynamicCredentialAuthFailureNanos = 0L; long nowWarn = System.nanoTime(); if (nowWarn - lastTransportWarnNanos >= 5_000_000_000L) { if (t instanceof QwpVersionMismatchException) { 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 c95f1f026..16762678c 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 @@ -31,6 +31,7 @@ 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; @@ -415,6 +416,43 @@ public void testRotatingCredentialAuthRideOutTerminatesOnAnUnboundedBudget() thr }); } + @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. + assertTrue("the outage must not have satisfied the dwell [attempts=" + factory.attempts() + "]", + factory.attempts() > 7); + assertTrue("and the ceiling must not be what ended it [attempts=" + factory.attempts() + "]", + factory.attempts() < BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING); + assertEquals("exactly one abandonment report: " + captured, 1, captured.size()); + }); + } + @Test public void testReturnsClientOnSuccessFirstAttempt() throws Exception { assertMemoryLeak(() -> { From 45f87aa356a3da1cf514a3aff6cc1f23d8ac2930 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 16:02:38 +0100 Subject: [PATCH 123/192] Back off after a failed silent refresh getToken() re-attempted the refresh on every call. It runs once per ILP flush and once per WebSocket (re)connect, and a producer retrying its rows calls it in a tight loop, so a revoked refresh token or an IdP outage cost a full token-endpoint round trip PER CALL: a sustained request flood at the provider - enough to trip its rate limits and lengthen the very outage being retried - while each call blocked the producer for that round trip, up to the OS TCP-connect timeout against a black-holed endpoint. A failed refresh now latches a timestamp and getToken() skips the network attempt for five seconds. The failure is still reported on every call, so nothing is hidden from the caller; only the request rate is bounded. Deliberately short: this is a stampede guard, not a circuit breaker, so a credential that comes back is picked up almost at once. signIn() neither consults nor keeps the latch - it is the explicit action a user takes to recover, and it falls through to the interactive flow anyway - and clearCache() resets it with the rest of the token state. The zero-elapsed case is the common one, not an edge case: a producer calls getToken() many times inside one millisecond, which is exactly the flood being stopped, so it counts as backed off. Only a NEGATIVE span, meaning the clock jumped backwards, releases the latch early rather than pinning it until the clock catches up. My first version tested elapsed > 0 and let the whole flood through; the test caught it at 25 round trips for 25 calls. Without the latch the new test fails with AssertionError: 25 getToken() calls must not mean 25 token-endpoint round trips expected:<1> but was:<25> Two documentation corrections in the same file ride along, both about this class rather than about the back-off: - the getToken() javadoc said the concurrent-caller wait is "capped here by httpTimeoutMillis". It is capped at FOUR times that - the holder's own worst case, since a refresh under the lock runs a send, an await and a body parse, each separately bounded - so the real ceiling is two minutes at the 30s default, not thirty seconds. A producer sizing flush backpressure against the documented figure got it four times wrong. - a comment above warnPersistence still said the store failure is reported to System.err. It has been an SLF4J warning since b7bb36de. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 52 ++++++++++++-- .../auth/OidcDeviceAuthPersistenceTest.java | 71 +++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) 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 index 9aec6f52c..55348ceda 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -153,6 +153,19 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 + * the OS TCP-connect timeout 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; // 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; @@ -206,6 +219,7 @@ public class OidcDeviceAuth implements QuietCloseable { private JsonLexer jsonLexer; private String lastPersistedRefreshToken; private HttpClient plainClient; + private long refreshFailedAtMillis; private String refreshToken; private boolean storeLoadAttempted; private HttpClient tlsClient; @@ -421,6 +435,7 @@ public void clearCache() { expiresAtMillis = 0; tokenTtlMillis = 0; lastPersistedRefreshToken = null; + refreshFailedAtMillis = 0; if (tokenStore != null) { try { tokenStore.clear(storeKey); @@ -492,8 +507,12 @@ public String getAuthorizationHeaderValue() { * 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 by - * {@link Builder#httpTimeoutMillis(int)} and still failing fast the moment an interactive sign-in or + * 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 @@ -529,9 +548,18 @@ public String getToken() { // 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. - if (refreshToken != null && tryRefreshCoordinated()) { + // 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. + if (refreshToken != null && !isRefreshBackedOff() && tryRefreshCoordinated()) { + refreshFailedAtMillis = 0; return selectToken(); } + if (refreshToken != null) { + 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"); } @@ -570,6 +598,10 @@ public String signIn() { // 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(); } @@ -1333,6 +1365,18 @@ private boolean isHttpStatusTransient() { 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 void maybeLoadFromStore() { if (tokenStore == null || storeLoadAttempted) { return; @@ -1839,7 +1883,7 @@ private boolean tryRefreshCoordinated() { } private void warnPersistence(String operation, Throwable cause) { - // best-effort persistence: report to System.err and carry on with the in-memory token. The store never + // 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). 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 index efa611256..7f0bc3940 100644 --- 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 @@ -365,6 +365,77 @@ public void testEntryWithNoTokenOfEitherKindIsNotAdopted() throws Exception { }); } + @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 = 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 testGetTokenAsFirstCallAfterRestore() throws Exception { assertMemoryLeak(() -> { From e900d94a670bb17a87fc9d3c9a574c67b7d637cf Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 16:03:29 +0100 Subject: [PATCH 124/192] Say SLF4J where the docs promised System.err Four places told an operator the client's persistence warnings go to System.err. They have gone to SLF4J at WARN since b7bb36de, and the library ships slf4j-api with no binding, so an application that has not added one sees nothing at all - SLF4J 2.x discards every record after a single startup line. The one that matters is the Windows warning. README told an operator to watch stderr for "could not enforce 0600/0700", which is how they learn their persisted refresh token is protected only by the profile directory's ACL rather than by file permissions. Following that instruction on a no-binding classpath produced silence, and silence reads as "the permissions were enforced". Corrected in all four, with the binding requirement stated where an operator will act on it: - TokenStore's javadoc, which is exported and ships in the javadoc jar - README's persistence section - design/oidc-token-persistence.md, which is the frozen cross-language contract the Python client mirrors, so it now says "SLF4J at WARN, or whatever the equivalent warning channel is in your language" rather than naming a Java stream - the comment above warnPersistence (in the previous commit, which touched the same file) Also pins the rendering of the most common ILP failure. QuestDB's LineHttpProcessorState builds its row error starting with a real newline, which escapeJsonStr sends as a JSON escape; the lexer now decodes that and putAsPrintable re-escapes it, so the text a user and their log scraper see changed from a two-character escape to a six-character one. Neither leaks a raw newline - that is what putAsPrintable is for - but the rendering is user-visible and nothing pinned it: the sibling tests assert only that fragments either side of the newline survive, which holds under both. Disabling the lexer's unescape now fails the new test with the old rendering in the message. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../client/cutlass/auth/TokenStore.java | 2 +- .../line/LineHttpSenderErrorResponseTest.java | 42 +++++++++++++++++++ design/oidc-token-persistence.md | 4 +- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5a9070c5f..bc122b157 100644 --- a/README.md +++ b/README.md @@ -477,7 +477,7 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( `FileTokenStore.atDefaultLocation()` writes one file per identity 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, so tokens for different servers or identities never collide. 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. -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 prints a one-line warning to `System.err` the first time it cannot enforce them. 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 — a tampered, corrupt, oversized, or identity-mismatched entry is ignored (the client falls back to a refresh or an interactive sign-in), and a token carrying control or non-ASCII characters is never placed on the wire. +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 — a tampered, corrupt, oversized, or identity-mismatched entry is ignored (the client falls back to a refresh or an interactive sign-in), and a token carrying control or non-ASCII characters is never placed on the wire. `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). 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 index 585af5fa6..f1f340542 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -42,7 +42,7 @@ * 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 to {@code System.err} and continues with the in-memory + * 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 { 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 index 08e4a719f..305af1a9d 100644 --- 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 @@ -318,6 +318,48 @@ public void testServerJsonErrorBidiAndZeroWidthAreEscaped() throws Exception { }); } + @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(() -> { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 022cf6e8f..d6e0e3c2e 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -545,7 +545,9 @@ Resolved: the audited `signIn`/`getToken`/`tryRefresh` gate. - **Plaintext JSON**, confidentiality via file permissions; encryption only via the SPI (Q1). -- **`System.err`** for the one best-effort persistence-failure warning. +- **SLF4J at `WARN`** for the one best-effort persistence-failure warning (the Java client ships + `slf4j-api` only, so an application without a binding sees nothing; a client in another language + should use whatever its own ecosystem's equivalent warning channel is). - **Opt-in** (no store unless the caller sets one). - **Ship `FileTokenStore`** as the default; keychain/KMS via the SPI. - **Frozen on-disk contract** (path, hash, schema, atomic write, lock-file protocol), From d7416d5ccd6c8c59d98010d9fa138ff19569cd81 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:02:39 +0100 Subject: [PATCH 125/192] Decline a silent refresh on a cancelled caller A silent refresh is a network round trip - exactly the work a cancellation is trying to stop - so getToken() now declines it when the calling thread already carries an interrupt, and says so. d3d3650b stopped inLock destroying such a flag. What it left behind was three separate problems, all from the guard living inside FileTokenStore.inLock rather than in getToken(): - with NO token store configured the guard did not exist at all. 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() to sign in again". 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 perfectly good. - the decline then latched the refresh back-off added in 45f87aa3, so one interrupt-carrying caller suppressed the next five seconds of legitimate refreshes for every thread sharing the instance. That third one I introduced two commits ago and had not noticed; the tests here pin it. The check is isInterrupted(), never interrupted(): the flag is the caller's cancellation signal and must survive the call, exactly as FileTokenStore.load() and save() preserve it. It sits after the cache check, so a caller holding a valid token is still served - that path does no I/O and there is nothing to cancel. expireCachedToken moves from private to package-private in OidcDeviceAuthTest rather than being copied: a second body would have been the third copy of that reflection in one package, and there is no non-reflective route (expires_in is clamped to a default when non-positive, and the smallest usable value leaves a live window that would have to be slept out). Without the guard both new tests fail: AssertionError: a cancelled caller must not get a token out of a network refresh AssertionError: a cancelled caller must not drive a refresh even with no token store Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 19 +++++ .../auth/OidcDeviceAuthPersistenceTest.java | 82 +++++++++++++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 6 +- 3 files changed, 106 insertions(+), 1 deletion(-) 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 index 55348ceda..8f87e4a28 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -553,6 +553,25 @@ public String getToken() { // 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"); + } if (refreshToken != null && !isRefreshBackedOff() && tryRefreshCoordinated()) { refreshFailedAtMillis = 0; return selectToken(); 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 index 7f0bc3940..d3f44af0d 100644 --- 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 @@ -436,6 +436,88 @@ public void testSignInClearsTheRefreshBackOff() throws Exception { }); } + @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(() -> { 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 index 88dadc29c..0bb69062f 100644 --- 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 @@ -3829,7 +3829,11 @@ public void testShortAllDigitStatusNotTreatedAsTransientOrTerminal() throws Exce // 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. - private static void expireCachedToken(OidcDeviceAuth auth) throws Exception { + // 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 From 3f94ec2fc560071c3e69cca3605020f2b622d72d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:31:03 +0100 Subject: [PATCH 126/192] Clamp the rotating-401 dwell instead of capping attempts Replaces the attempt ceiling from 71c04f9c with a clamp on the dwell itself, which is the right shape for this gate and closes a case the ceiling did not. Both fix the same defect: reconnect_max_duration_millis is validated only as > 0, Long.MAX_VALUE is the documented "never give up", TimeUnit saturates it, and the AND gate could then never complete - so an orphan drainer swept forever against a dead credential, holding the slot lock and one worker of a fixed-size pool. The ceiling was the weaker instrument. It bounded ATTEMPTS while the thing being bounded is TIME, so its real duration moved with reconnect_max_backoff_millis - hours at a large backoff. Worse, at a SMALL backoff it fired before the dwell it was meant to backstop: 240 attempts at the 1s max backoff the e2e failover suite configures is about four minutes, inside the five-minute dwell, so the ceiling would have quarantined a slot the dwell floor was still deliberately riding out. Two guards over one window, and the redundant one could preempt the primary. Clamping the dwell removes it rather than tuning it. The ceiling value is the DEFAULT reconnect budget, not 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 and every existing test that drives a 25ms budget is unaffected. The clamp is exposed as a public pure function so it can be asserted without waiting it out; proving it end to end means five minutes of wall clock in a test. The other half of the argument - that a FINITE dwell does quarantine - is what testRotatingCredentialAuthRejectionQuarantines- OnceBudgetExhausted already drives at 25ms. Finite dwell quarantines, and the dwell is now always finite. Unclamped, the new assertion fails with expected:<300000000000> but was:<9223372036854775807> Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 75 +++++++++++++------ .../BackgroundDrainerDurableAckRetryTest.java | 61 +++++++-------- 2 files changed, 82 insertions(+), 54 deletions(-) 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 51773fb8f..37b2abceb 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 @@ -110,22 +110,27 @@ public final class BackgroundDrainer implements Runnable { */ public static final int DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS = 6; /** - * Hard ceiling on rotating-credential {@code 401}/{@code 403} sweeps, whatever the wall-clock dwell - * says. The dwell below is an AND with the attempt threshold - both must be exhausted - and it is - * derived from {@code reconnect_max_duration_millis}, which is validated only as {@code > 0} and whose - * documented way to ask for "never give up" on reconnect is {@code Long.MAX_VALUE}. {@code TimeUnit} - * saturates that to {@code Long.MAX_VALUE} nanos, so the dwell conjunct could never be satisfied 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 is an OR, so its attempt cap already survives the same saturation; this is - * the equivalent guarantee for a gate that cannot be an OR without losing its dwell floor. + * Ceiling on the rotating-credential {@code 401}/{@code 403} wall-clock dwell, independent of the user + * knob it is otherwise derived from. *

    - * Sized far above any legitimate ride-out rather than as a second threshold: at the default - * {@code reconnect_max_backoff_millis} of 5s, the default 5-minute dwell is satisfied in roughly 60 - * sweeps, so this only bites after four times that - by which point the credential is not healing. + * 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 int MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING = 240; + 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 @@ -325,6 +330,23 @@ public BackgroundDrainer() { * @return a fresh durable-ack-capable client, or {@code null} if * {@link #outcome} has been set to FAILED or STOPPED */ + /** + * 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)); + } + public WebSocketClient connectWithDurableAckRetry() { // run() already set runnerThread; setting it again here is a no-op // on that path but wires up direct callers so requestStop() @@ -370,6 +392,11 @@ public WebSocketClient connectWithDurableAckRetry() { // conversion guards the same way for the same reason. 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; @@ -409,15 +436,13 @@ public WebSocketClient connectWithDurableAckRetry() { firstDynamicCredentialAuthFailureNanos = now; } dynamicCredentialAuthElapsedNanos = now - firstDynamicCredentialAuthFailureNanos; - // The ceiling is a conjunct, not a third alternative: the ride-out still needs BOTH - // the attempt threshold and the dwell floor to quarantine, so a healing credential is - // never abandoned early. It exists only so an unsatisfiable dwell - a saturated - // reconnect_max_duration_millis, see MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING - - // cannot turn "ride it out" into "never escalate". + // 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. retryDynamicCredentialAuth = - dynamicCredentialAuthAttempts < MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING - && (dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS - || dynamicCredentialAuthElapsedNanos < reconnectBudgetNanos); + dynamicCredentialAuthAttempts < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS + || dynamicCredentialAuthElapsedNanos < dynamicCredentialAuthDwellNanos; } if (retryDynamicCredentialAuth) { lastErrorMessage = e.getMessage(); @@ -468,7 +493,8 @@ public WebSocketClient connectWithDurableAckRetry() { // 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 - // MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING backstops the escalation regardless. + // 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) { @@ -579,7 +605,8 @@ public WebSocketClient connectWithDurableAckRetry() { // 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 - // MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING backstops the escalation regardless. + // 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) { 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 16762678c..2ee759016 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 @@ -386,34 +386,35 @@ public void testRotatingCredentialAuthRejectionRidesOutBoundedBudget() throws Ex }); } - @Test(timeout = 60_000) - public void testRotatingCredentialAuthRideOutTerminatesOnAnUnboundedBudget() throws Exception { - assertMemoryLeak(() -> { - // 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 - could never be satisfied 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, starving every other orphan slot. Without the ceiling this call does - // not return and the test times out. - ScriptedFactory factory = ScriptedFactory - .alwaysFailing(() -> new QwpAuthFailedException(401, "127.0.0.1", 9000)) - .withDynamicCredential(); - BackgroundDrainer drainer = newDrainerWithBudgets( - factory, Long.MAX_VALUE, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS); - List captured = Collections.synchronizedList(new ArrayList()); - drainer.setErrorSink(captured::add); - - WebSocketClient out = drainer.connectWithDurableAckRetry(); - - assertNull(out); - assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome()); - assertEquals("the ceiling, not the unsatisfiable dwell, must end the ride-out", - BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING, 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 + 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. + // + // Asserted on the clamp directly rather than end to end: proving it through connectWithDurableAckRetry + // means waiting out the ceiling, five minutes of wall clock. The other half of the argument - that a + // FINITE dwell does quarantine - is what testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted + // drives, with a 25ms budget. Finite dwell quarantines, and the dwell is always finite. + 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) @@ -445,10 +446,10 @@ public void testTransientOutageDoesNotCountTowardTheRotating401Dwell() throws Ex // 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); - assertTrue("and the ceiling must not be what ended it [attempts=" + factory.attempts() + "]", - factory.attempts() < BackgroundDrainer.MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_CEILING); assertEquals("exactly one abandonment report: " + captured, 1, captured.size()); }); } From 1a9f7651a3ce273ba94cafb0956b2749f612a9eb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:35:02 +0100 Subject: [PATCH 127/192] Correct what the rotating-401 comments promise The dwell half of this issue was fixed in 4b7e4ed2: the anchor now restarts in the transient arms, so the floor measures how long the credential stayed rejected rather than how long the drainer had been running. What was left is the mirror, and it is comment-only. dynamicCredentialAuthAttempts claimed "Never reset ... per-drain, not per-episode". It is a local of connectWithDurableAckRetry(), and a mid-drain terminal recycles the wire and re-enters that method, so it restarts at zero. A credential accepted at connect and rejected only mid-drain, repeatedly, therefore defers the escalation indefinitely. That is the tolerable direction - the slot keeps its replayable rows and no .failed sentinel is dropped on a fault that may still heal - and it is the same off-by-one the capability-gap recycle already carries. Making it genuinely per-drain means hoisting it to a field, which quarantines such a slot sooner. That is a behaviour change rather than a comment fix, so it is not made here; the comment now says so instead of claiming a property the code does not have. The constant's javadoc had also drifted under two of my own commits and is corrected with it: the floor is no longer "reconnectMaxDurationMillis measured from the first rejection" but the CLAMPED dwell (3f94ec2f) measured from the first rejection of the current uninterrupted run (4b7e4ed2). Both changes were made to satisfy what that paragraph already promised, so leaving it describing the old mechanism would have made an accurate guarantee read as a stale one. No test: nothing executable changed. The behaviour these comments describe is already pinned by testTransientOutageDoesNotCountTowardTheRotating401Dwell and testRotatingCredentialAuthDwellIsClampedSoEscalationStaysReachable. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) 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 37b2abceb..dc8c87c07 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 @@ -99,11 +99,15 @@ public final class BackgroundDrainer implements Runnable { * ({@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 {@code reconnectMaxDurationMillis}, measured from the first rejection. 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. + * 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. @@ -363,10 +367,19 @@ public WebSocketClient connectWithDurableAckRetry() { // fresh consecutive run before quarantine is permitted. int capabilityGapAttempts = 0; // 401/403 sweeps ridden out so far, counted only for a ROTATING credential (see - // DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS). Never reset: unlike the capability-gap episode - // this threshold is per-drain, not per-episode, so a credential that alternates - // rejected/unreachable cannot refill it indefinitely and stall the quarantine that an operator - // needs to 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 local, though, not per-DRAIN: a mid-drain terminal recycles the wire and re-enters + // connectWithDurableAckRetry(), which starts it at zero again. So a credential that is accepted at + // connect and only rejected mid-drain, repeatedly, defers the escalation indefinitely. That is the + // tolerable direction - the slot keeps its replayable rows and no .failed sentinel is dropped on a + // fault that may still heal - and it is the same off-by-one the capability-gap recycle carries. + // Making it per-drain means hoisting it to a field, which quarantines such a slot sooner; that is a + // behaviour change, not a comment fix, so it is deliberately not made here. int dynamicCredentialAuthAttempts = 0; // 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 From b71f8b25be04623b21f638c526b0fda543a3dafa Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:43:58 +0100 Subject: [PATCH 128/192] Restart the 401 dwell on a capability gap too 4b7e4ed2 restarted the rotating-401 dwell anchor in the transient arms, but connectWithDurableAckRetry has THREE arms that are not a 401 and it only covered two: role reject and the transport catch-all. The durable-ack capability-gap arm still charged its time to the dwell. That leaves the same defect alive in the arm most able to trigger it. A 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 401 dwell is meant to require of an UNINTERRUPTED rejection. So a 401, a rolling upgrade, then five more 401s satisfied both thresholds at once and quarantined an orphan slot over a credential rejected for seconds, abandoning replayable rows behind a .failed sentinel nothing in production clears. The accounting is now symmetric, which is what "mirror the capability-gap accounting" amounts to here: the capability-gap trio is restarted by all three arms that are not a gap (auth, role reject, catch-all), and the auth anchor is restarted by all three arms that are not a 401 (role reject, capability gap, catch-all). Each budget is charged only by its own condition. Not a regression, as with the sibling fix: the merge base quarantined on the FIRST 401 unconditionally, so head is better than base for every input either way. Without the reset the new test fails with AssertionError: a capability gap must not have satisfied the dwell [attempts=7] - quarantine on the sixth rejection, the gap's wall clock having paid for the floor. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 8 ++++ .../BackgroundDrainerDurableAckRetryTest.java | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) 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 dc8c87c07..da52cb44a 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 @@ -532,6 +532,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, 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 2ee759016..aadde6986 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 @@ -454,6 +454,45 @@ public void testTransientOutageDoesNotCountTowardTheRotating401Dwell() throws Ex }); } + @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 public void testReturnsClientOnSuccessFirstAttempt() throws Exception { assertMemoryLeak(() -> { From 36935a7ad8c60870a2faa046f1efda95a1261d1b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:54:21 +0100 Subject: [PATCH 129/192] Hold the ride-out budgets per drain, not per call run() re-enters connectWithDurableAckRetry() after every mid-drain terminal, and the escalation counters were locals of that method - so each recycle refilled the budget it exists to spend. A cluster that flaps (connect accepted, drop, 401, recycle, repeat) looped forever with no ack progress: the escalation never arrived, the slot lock was never released, and one of max_background_drainers workers - four by default - stayed pinned. Four such slots starve every other orphan slot of a drainer, so nothing is lost but nothing is delivered either, and no operator ever sees the quarantine. capabilityGapAttempts, dynamicCredentialAuthAttempts and firstDynamicCredentialAuthFailureNanos become instance fields. The capability-gap side had the identical defect: run() recycles on either terminal, so the 16-sweep settle budget was refilled the same way. The anchor has to move with them, which is the part that is not obvious. The rotating-401 gate is an AND, so promoting the attempt counter alone fixes nothing: each recycle would still restart the dwell, the second conjunct would never complete, and the flap would continue. The dwell has to span recycles for the escalation to be reachable at all. That is deliberately NOT a return to the entry-anchored floor fixed in 4b7e4ed2 and b71f8b25: the transient arms - role reject, capability gap, transport - still reset the anchor, so time the drainer spent unable to reach anyone, or talking to a node that cannot do durable ack, is still not charged to the credential. What now counts is a successful connect followed by another rejection, which is exactly the flap this is meant to catch. The two wall-clock helpers beside them stay per-call on purpose: capabilityGapElapsedNanos and lastCapabilityGapNanos measure an UNINTERRUPTED run, and a successful connect plus a drain is an interruption, so a fresh call should start their accounting over. I argued against this in 1a9f7651 on the grounds that it errs safely - the slot keeps its replayable rows. That weighed the slot and missed the pool: a pinned worker denies service to every other orphan slot, so the safe-looking direction is not safe at pool scale. With the counters restored to per-call the new test fails with AssertionError: a flapping credential must reach the escalation instead of recycling forever expected null, but was: Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 38 +++++++++----- .../BackgroundDrainerDurableAckRetryTest.java | 50 ++++++++++++++++++- 2 files changed, 75 insertions(+), 13 deletions(-) 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 da52cb44a..c693d4b25 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 @@ -157,6 +157,24 @@ public final class BackgroundDrainer implements Runnable { private final String slotPath; private final long syncIntervalNanos; /** Latest known {@code engine.ackedFsn()}; published for visibility. */ + /** + * Escalation counters for the two bounded ride-outs, held per DRAIN rather than per call. + *

    + * They 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. + *

    + * 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. These three measure the whole + * drain, which is the span a quarantine decision is about. + */ + private int capabilityGapAttempts; + private int dynamicCredentialAuthAttempts; + private long firstDynamicCredentialAuthFailureNanos; private volatile long ackedFsn = -1L; /** * Engine constructed by {@link #run()}, captured for test observation @@ -365,7 +383,7 @@ 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 @@ -373,20 +391,16 @@ public WebSocketClient connectWithDurableAckRetry() { // the dwell anchor beside it, which does restart because it measures persistence rather than // count. // - // It is a local, though, not per-DRAIN: a mid-drain terminal recycles the wire and re-enters - // connectWithDurableAckRetry(), which starts it at zero again. So a credential that is accepted at - // connect and only rejected mid-drain, repeatedly, defers the escalation indefinitely. That is the - // tolerable direction - the slot keeps its replayable rows and no .failed sentinel is dropped on a - // fault that may still heal - and it is the same off-by-one the capability-gap recycle carries. - // Making it per-drain means hoisting it to a field, which quarantines such a slot sooner; that is a - // behaviour change, not a comment fix, so it is deliberately not made here. - int dynamicCredentialAuthAttempts = 0; + // 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. The attempt threshold, unlike this, never resets during the drain. A zero value means no - // rejection has been observed. - long firstDynamicCredentialAuthFailureNanos = 0L; + // 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 // reconnectBudgetNanos (or the attempt cap fires first). 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 aadde6986..36a24caa0 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 @@ -493,6 +493,54 @@ public void testCapabilityGapDoesNotCountTowardTheRotating401Dwell() throws Exce }); } + @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 public void testReturnsClientOnSuccessFirstAttempt() throws Exception { assertMemoryLeak(() -> { @@ -1161,7 +1209,7 @@ private BackgroundDrainer newDrainer(ScriptedFactory factory) { } private BackgroundDrainer newDrainerWithBudgets( - ScriptedFactory factory, + CursorWebSocketSendLoop.ReconnectFactory factory, long reconnectMaxDurationMillis, long backoffInitMillis, long backoffMaxMillis) { From a974adb28fc55c8ad58a6f058a18a9c5926419e8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:59:40 +0100 Subject: [PATCH 130/192] Roll back ResponseHeaders' own buffer too The base constructor's rollback frees what IT staged - the socket and the two mallocs - but ResponseHeaders was constructed straight into the final field. HttpHeaderParser mallocs the 4096-byte header parse buffer as its first statement, so a heap OOM in either allocation after it stranded those bytes: HttpClient's catch (Throwable) never holds a reference to a ResponseHeaders that failed to finish constructing, so it cannot free what that object had already taken. Staging it outside would not help, because the throw happens INSIDE the constructor and no reference escapes. The rollback has to live where the memory was taken, so ResponseHeaders now guards its own body after super() and calls super.close() - gated on headerPtr != 0, so safe and idempotent - before rethrowing. Same rule as the enclosing constructor: whoever took it frees it when construction cannot complete. HttpClientWindows took its select facade after the guard, so an override that threw would have stranded the FDSet as well as everything the base constructor took. It moves inside, and the catch frees the FDSet with it. Linux and Osx already evaluated every configuration getter inside their guard; Windows was the odd one out. Unreachable with the shipped default facade, which cannot throw. Base leaked strictly more on both paths - the whole socket plus 64 KB plus 64 KB plus the 4 KB - so neither is a regression; both are residual gaps in the rollback this branch introduced. Neither is unit-testable here, and no test is added rather than one that passes for the wrong reason: - the ResponseHeaders window needs a throw between super()'s malloc and the end of the constructor. I checked every statement in it: both ResponseImpl and ChunkedResponseImpl bottom out in constructors that only assign their arguments, so nothing there can be made to fail from outside. Reaching it needs a genuine heap OOM or a new injection seam. - the Windows path needs a Windows host AND a custom HttpClientConfiguration whose getSelectFacade() throws. HttpClientConstructorLeakTest still covers the four injectable failure points, and all 725 tests on the affected surface stay green. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/http/client/HttpClient.java | 17 ++++++++++++++--- .../cutlass/http/client/HttpClientWindows.java | 7 ++++++- 2 files changed, 20 insertions(+), 4 deletions(-) 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 c3ecac3bf..3849a6d86 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 @@ -884,9 +884,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() { 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 af8edbfde..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 @@ -39,13 +39,18 @@ public HttpClientWindows(HttpClientConfiguration configuration, SocketFactory so super(configuration, socketFactory); // 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; } - this.sf = configuration.getSelectFacade(); } @Override From cda4a81f270b5f3dcbaaf56d5a867c9ebf984de0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 18:05:40 +0100 Subject: [PATCH 131/192] Report an empty buffer as address zero, not -1 Sender.bufferView() handed out a view that is empty by length but whose base address is the -1 sentinel. getContentLength() already reported 0 for that state, so the two accessors disagreed: a ptr() != 0 test reads as true on an empty buffer, and pointer arithmetic on -1 is nonsense. The 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 contentStart holds its sentinel between every flush and the next row - which is most of the time for a sender that flushes per batch. Before this branch newRequest() always opened the content section, so the sentinel never escaped. Fixed at getContentStart() rather than at bufferView(): the accessor is public on an exported class, it is where the matching guard on trimContentToLen already lives, and bufferView() is its only in-tree caller, so nothing depends on the sentinel leaking out. Its contract is now the same one getContentLength() has kept all along - report the empty state as empty. Without the guard the new test fails with AssertionError: an empty buffer must report a zero base address, not the -1 sentinel expected:<0> but was:<-1> Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/http/client/HttpClient.java | 16 ++++++++++- .../line/LineHttpSenderTokenProviderTest.java | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 3849a6d86..bb8e6d2c9 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 @@ -353,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() { 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 index d3dea466a..8b87f82e2 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -57,6 +58,32 @@ */ 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(() -> { From 75ad4b86ab061fde99f9f3fd709e2f24bfbb5809 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 18:13:05 +0100 Subject: [PATCH 132/192] Close three security residuals in the token store Three unrelated gaps, all in how the store treats operator-supplied input. An IO error message embeds the path it failed on, and that path comes from questdb.client.oidc.token.store.dir - so it is the one untrusted string these warnings put in front of a terminal, and three LOG.warn calls emitted it raw. A crafted directory name could reorder, hide or forge the surrounding log line with ANSI, CR/LF or bidi overrides. The class already knew this: warnNoPosixPermsOnce omits the path deliberately and OidcDeviceAuth.warnPersistence sanitizes for exactly this reason. All three now go through the same sanitizer, which is widened to package-private rather than copied - a second walk of that code in one package is the thing to avoid. restrictToOwner narrowed any pre-existing directory to 0700 silently. The tightening stays unconditional, because it IS the at-rest protection of the plaintext token files and removing it would undo the model - but it changes a directory the operator chose and may share with something else, so it now says so once per JVM, without naming the path. isLoopbackHost accepted the NAME localhost on its spelling. RFC 6761 says it must be 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 permits a device code and a refresh token to travel in cleartext. It now resolves the name and requires EVERY answer to be loopback, failing closed when resolution fails - the caller then demands https, which is never the less safe answer. Address literals in 127.0.0.0/8 keep the pure-string path: they need no resolution because they ARE the address. On coverage, plainly: the loopback change is pinned in both directions that a test can reach - every accepted form still resolves and is accepted, and a name that does not resolve fails closed. The hostile half (localhost resolving OFF loopback) needs the host's resolver rewritten, so it is unasserted by design. The two logging changes are not asserted at all: proving them means capturing SLF4J output through a test appender, which the suite has no fixture for, and a test that merely called the sanitizer would pin nothing about the call sites. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 22 ++++++++-- .../client/cutlass/auth/OidcDeviceAuth.java | 41 +++++++++++++++++-- .../test/cutlass/auth/OidcDeviceAuthTest.java | 9 ++++ 3 files changed, 65 insertions(+), 7 deletions(-) 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 index b19c4bbcd..2a55a4600 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -169,6 +169,7 @@ public final class FileTokenStore implements TokenStore { // 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; @@ -399,8 +400,11 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // 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={}]", e.getMessage()); + + "refresh until it goes stale [error={}]", + OidcDeviceAuth.sanitizeForDisplay(e.getMessage())); } finally { if (wasInterruptedInSection) { Thread.currentThread().interrupt(); @@ -776,8 +780,9 @@ private static void releaseLock(Path lock, String nonce) { // 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={}]", e.getMessage()); + + "it goes stale [error={}]", OidcDeviceAuth.sanitizeForDisplay(e.getMessage())); } } @@ -837,6 +842,15 @@ private static boolean restrictToOwner(Path directory) throws IOException { 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; @@ -961,8 +975,10 @@ private String acquireLock(Path lock) throws InterruptedException { // 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={}]", e.getMessage()); + + "cross-process coordination [error={}]", + OidcDeviceAuth.sanitizeForDisplay(e.getMessage())); return null; } } 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 index 8f87e4a28..722469541 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -50,7 +50,9 @@ 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; @@ -910,9 +912,38 @@ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issu } private static boolean isLoopbackHost(String host) { - // loopback traffic never leaves the host, so a plaintext /settings fetch to it has no network - // interception risk; match localhost and the whole IPv4 127.0.0.0/8 block - return host != null && (host.equalsIgnoreCase("localhost") || (host.startsWith("127.") && isDottedIpv4(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) { @@ -1075,7 +1106,9 @@ private static void requireSecureTransport(boolean isTls, String label, String u } } - private static String sanitizeForDisplay(String value) { + // 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; } 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 index 0bb69062f..0d682d75d 100644 --- 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 @@ -2389,6 +2389,15 @@ public void testLoopbackHostClassifierAcceptsLoopbackForms() throws Exception { for (String s : loopback) { Assert.assertTrue("expected loopback: [" + s + "]", invokeIsLoopbackHost(s)); } + // The name is now 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, and this + // exemption is what lets a device code and a refresh token travel in cleartext. The assertions above + // therefore also pin that the resolution path still accepts the normal case - break it and every + // loopback form spelt as a name fails closed, which would be safe but would refuse a working local + // dev setup. The hostile half (localhost resolving OFF loopback) cannot be reached from a test + // without rewriting the host's resolver, so it is unasserted by design rather than by omission. + Assert.assertFalse("a name that does not resolve must fail closed", + invokeIsLoopbackHost("no-such-host.invalid")); } @Test(timeout = 30_000) From 6bc8528d9e80992e071c272f6d66f79fb70c99f7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 22:52:54 +0100 Subject: [PATCH 133/192] Throttle a failing token store read instead of retrying per flush maybeLoadFromStore() deliberately leaves storeLoadAttempted unset when a read throws, so a transient fault is retried rather than disabling persistence for the life of the instance. It had no back-off, and it runs on the getToken() path AHEAD of the cache check - which an ILP producer reaches once per flush. A store that never becomes readable - a chmod or uid mismatch in a container, EIO or ESTALE on an NFS home - therefore cost a blocking file open, two stack trace fills and an unthrottled WARN line on every flush, forever, on the producer thread and under this instance's lock. A store with nothing to return was never affected: load() reports that by returning null, which latches the flag outright. A failed read now arms a back-off that doubles per consecutive failure, from the 5s floor the refresh back-off already uses up to a 60s ceiling. The FIRST failure arms a zero-length one, so the very next call still re-reads: 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 at once, and that is what testTransientStoreLoadFailureIsRetriedNotLatched pins. Anything that survives the free retry needs an operator, so waiting is no longer holding off a recovery that was about to happen anyway. isStoreLoadBackedOff() releases the latch when the remaining span exceeds the ceiling: nothing here can arm one that long, so it means the clock jumped backwards - the same call isRefreshBackedOff() makes, for the same reason. signIn() clears the back-off outright, as it already clears the refresh one. It is an explicit user action about to spend a whole device flow, so it is never the caller this throttles, and sending a human through that flow over a refresh token sitting on a disk that is readable again is the outcome persistence exists to prevent. TokenStore.load()'s contract now states which of the two answers is definitive, because an implementer choosing between null and a throw is choosing between "stop reading" and "retried behind a back-off". Two tests: 25 getToken() calls against a permanently unreadable store must cost 2 reads rather than 25, and signIn() must re-read a store the back-off is holding off, serving the persisted token without a device flow. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 52 ++++++++++++- .../client/cutlass/auth/TokenStore.java | 5 ++ .../auth/OidcDeviceAuthPersistenceTest.java | 78 +++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) 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 index 722469541..402b86335 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -168,6 +168,14 @@ public class OidcDeviceAuth implements QuietCloseable { * {@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; @@ -184,6 +192,13 @@ public class OidcDeviceAuth implements QuietCloseable { // 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; @@ -220,10 +235,16 @@ public class OidcDeviceAuth implements QuietCloseable { 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 @@ -603,6 +624,13 @@ 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) @@ -1429,8 +1457,17 @@ private boolean isRefreshBackedOff() { 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) { + if (tokenStore == null || storeLoadAttempted || isStoreLoadBackedOff()) { return; } PersistedToken token; @@ -1438,10 +1475,21 @@ private void maybeLoadFromStore() { 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 the next call. Latching it here instead would make one failed + // 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; } 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 index f1f340542..e5275e208 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -102,6 +102,11 @@ default boolean inLock(TokenStoreKey key, CriticalSection action) { * 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} 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 index d3f44af0d..c86afbe65 100644 --- 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 @@ -1228,6 +1228,84 @@ public void testTransientStoreLoadFailureIsRetriedNotLatched() throws Exception }); } + @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 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") From 131a78c19b1a919e110d20a416422c9c1b664429 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 23:26:33 +0100 Subject: [PATCH 134/192] Give the weak tests teeth and stop repeating the failure block Test-code only; no product change. Seven separate defects, all of the same family: a test that reads as covering something it does not. The seven-line try/fail/catch around a sign-in that must fail was stamped out at forty sites in OidcDeviceAuthTest, while assertBuildFails sat in the same file showing the idiom. assertOidcFails now takes the call and the expected message fragment and hands the OidcAuthException back, so the thirty-odd sites with extra checks keep asserting on it. It also closes an AutoCloseable it gets back before failing, so a construction that should have been rejected cannot leak past the assertion, and it reports the value the call returned - on a token path that IS the credential an over-permissive check let through. Six blocks stay as they were: two assert only the exception type, two assert an OR of two fragments, one calls a void clearCache(), one has no positive message assertion to bind to. QwpQueryClientTokenProviderTest hand-rolled a 57-line ServerSocket with two catch (Exception ignored) blocks to capture one Authorization header. Every harness fault - a broken accept, read or write - therefore arrived as a MISSING header, i.e. as a product regression. MockOidcServer already records the header of every request it reads and resurfaces a handler throwable on close(); the test uses it and drops to a dozen lines. Two comment blocks in BackgroundDrainerDurableAckRetryTest still narrated the TDD red step as present-tense fact - "currently lumps role rejects in with ... Goes green once ..." - describing behaviour this branch already split apart. They now state what the drainer does and name the regression each test pins. EngineCloseSlotLockReleaseTest's javadoc had the same defect ("The current code propagates the NPE ... After the fix ... the test goes green") and gets the same treatment; the try/finally it describes is in CursorSendEngine.finishClose. Four assertions asserted less than they appeared to: - testConcurrentStealContentionDegradesCleanly checked only ran == threads, which holds with the whole acquire deleted, since inLock() runs the section lock-free when it cannot get a lock. Each contender now records the lock stamp while it runs, and every one must be a live stamp - neither absent nor the crashed holder's - with the lock released at the end. Its four threads also never contended: PROCESS_LOCKS serializes same-identity threads ahead of every lock-file syscall, which the comment now says outright. The capture race that IS concurrent gets its own test, driving stealIfStale reflectively from eight threads - the only way to reach it in one JVM. - testGetTokenDegradesWhenStoreLockHeld allowed 10s against a 200ms acquire budget and could not tell a degrade from an acquire. It is now bounded on both sides (the budget must be spent; 2s catches a regression to the 3s default) and asserts the decisive thing: the peer's lock file survives, where an acquire would have deleted it on release. - the "waiter is blocked" latch counted down as the first statement of the thread body, BEFORE the getToken() it gated, so it proved only that the thread had been scheduled and every later assertion rested on a sleep. The test now polls the waiter's own stack for the getToken frame, and re-checks it after the sleep. - the auth dwell floor was 25ms against attempts that cost ~15ms on their own, so a slow machine satisfied it without the dwell being honoured. 250ms leaves the margin unmistakable. Three *DoesNotLeakNativeMemory tests bypassed assertMemoryLeak on the claim that it "does not reliably flag single-tag growth". It does: TestUtils.LeakCheck asserts per-tag equality across the whole MemoryTag range and then total equality. All three now run inside it; the named per-tag assertions stay, because they say WHICH buffer leaked. The store's Windows arms were untested and CI is Linux-only. The AccessDeniedException retry in replaceTarget - the sharing violation a Windows reader holding the target open produces - is now exercised by denying the rename with directory permissions instead: one test clears the denial mid-retry and requires the replace to complete, one lets it persist and requires the last denial to be rethrown after the full budget. The retry test proves the denial is real on the host before resting on it, so it cannot pass vacuously, and both skip as root. What is left - the UnsupportedOperationException non-POSIX fallbacks and the AtomicMoveNotSupportedException arm - needs a Windows agent or a synthetic FileSystemProvider, and is now stated in a class-level scope note instead of being invisible. The two assertOidcFails paths were mutation-checked: a wrong fragment fails with the real message, and a call that succeeds fails with "[expected an OidcAuthException containing ..., got A-TOKEN-IT-SERVED]". Full client suite: 3327 tests, 0 failures, 4 pre-existing skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/cutlass/auth/FileTokenStoreTest.java | 227 +++++++- .../auth/OidcDeviceAuthPersistenceTest.java | 23 +- .../test/cutlass/auth/OidcDeviceAuthTest.java | 493 ++++++++---------- .../QwpQueryClientTokenProviderTest.java | 64 +-- .../BackgroundDrainerDurableAckRetryTest.java | 29 +- .../EngineCloseSlotLockReleaseTest.java | 8 +- 6 files changed, 483 insertions(+), 361 deletions(-) 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 index b40070899..92057f9b6 100644 --- 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 @@ -37,14 +37,22 @@ import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.IOException; +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.PosixFilePermissions; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; @@ -54,6 +62,27 @@ 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 { @Rule public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build(); @@ -230,20 +259,34 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8)); Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000)); - // several "processes" race to steal the one abandoned lock. This exercises the steal path under - // N-way contention and asserts it degrades CLEANLY: every contender eventually runs its critical - // section (none is starved or wedged) and no atomic-capture temp file leaks. 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. + // 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 ReentrantLock on 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; @@ -263,10 +306,166 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { } 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(); + Files.createDirectories(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) { + t.join(); + } + + 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 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(); + Files.createDirectories(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(); + Files.createDirectories(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(() -> { @@ -1502,6 +1701,16 @@ 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"); 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 index c86afbe65..9e73feb28 100644 --- 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 @@ -552,13 +552,28 @@ public void testGetTokenDegradesWhenStoreLockHeld() throws Exception { 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 - Files.createFile(dir.resolve(keyFor(server).hash() + ".lock")); - try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(new FileTokenStore(dir, 200, 600_000)).build()) { + 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; - Assert.assertTrue("getToken must degrade promptly, not stall, was " + elapsed, elapsed < 10_000); - } + // 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); } 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 index 0d682d75d..efd2f157f 100644 --- 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 @@ -52,6 +52,7 @@ 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; @@ -82,13 +83,8 @@ public void testAccessDeniedSurfacesOauthError() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertEquals("access_denied", e.getOauthError()); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("the user declined")); - } + OidcAuthException e = assertOidcFails(auth::signIn, "the user declined"); + Assert.assertEquals("access_denied", e.getOauthError()); } }); } @@ -145,12 +141,8 @@ public void testAllControlVerificationUriRejectedAsIncomplete() throws Exception }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an all-control verification_uri to be rejected as incomplete"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete")); - } + assertOidcFails(auth::signIn, "incomplete", + "expected an all-control verification_uri to be rejected as incomplete"); } }); } @@ -685,13 +677,8 @@ public void testDeviceEndpointReturnsOauthError() throws Exception { MockOidcServer.json(400, "{\"error\":\"invalid_client\",\"error_description\":\"unknown client\"}"); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertEquals("invalid_client", e.getOauthError()); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("unknown client")); - } + OidcAuthException e = assertOidcFails(auth::signIn, "unknown client"); + Assert.assertEquals("invalid_client", e.getOauthError()); } }); } @@ -855,14 +842,10 @@ public void testNonNumericStatusCodeRejectedDuringPolling() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected a malformed status code on the poll path 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); - } + 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); } }); } @@ -928,14 +911,12 @@ public void testDiscoveryRejectsMalformedStatusWithoutEchoingIt() throws Excepti }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()).close(); - Assert.fail("a malformed status [" + statusToken + "] must not gate discovery open"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("malformed HTTP status code")); - Assert.assertFalse("the raw status must not be echoed: " + e.getMessage(), - e.getMessage().indexOf('\u001b') >= 0); - } + 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); } } }); @@ -960,13 +941,11 @@ public void testSettingsUnderErrorStatusNotTrustedAsConfig() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()); - Assert.fail("a 500 /settings body must not be trusted as OIDC configuration"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("did not return its settings")); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpStatus=500")); - } + 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")); } }); } @@ -1027,12 +1006,8 @@ public void testDiscoveryIgnoresArrayWrappedConfig() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler)) { serverRef.set(server); - try { - OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()); - Assert.fail("array-wrapped config must not be trusted as OIDC config"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("OIDC is not enabled")); - } + assertOidcFails(() -> OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure()), + "OIDC is not enabled", "array-wrapped config must not be trusted as OIDC config"); } }); } @@ -1165,24 +1140,27 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { 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. - // The module's assertMemoryLeak does not reliably flag single-tag growth, so measure both tags - // directly. Measuring only the parser tag (as an earlier version did) was blind to a leak of the - // HTTP client's native buffers - the resource most likely to be left dangling on the failure path. - 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)); + // 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) @@ -1356,15 +1334,10 @@ public void testEscapedErrorDescriptionDecoded() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertEquals("access_denied", e.getOauthError()); - // the escapes are decoded, not shown literally - Assert.assertTrue(e.getMessage(), e.getMessage().contains("it\"s a / test")); - Assert.assertFalse(e.getMessage(), e.getMessage().contains("\\/")); - } + 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("\\/")); } }); } @@ -1693,15 +1666,11 @@ public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception { // getToken() must return control promptly (here: throw), NOT block ~10s until // the device code expires and signIn() releases the lock long startNanos = System.nanoTime(); - try { - auth.getToken(); - Assert.fail("expected getToken() to fail fast while a sign-in is in progress"); - } catch (OidcAuthException e) { - long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; - Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight sign-in", - elapsedMillis < 2_000); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); - } + 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 @@ -1798,9 +1767,7 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except // a refresh holds the lock now; a second getToken() must WAIT for it, not fail fast AtomicReference waiterResult = new AtomicReference<>(); AtomicReference waiterError = new AtomicReference<>(); - CountDownLatch waiterStarted = new CountDownLatch(1); Thread waiter = new Thread(() -> { - waiterStarted.countDown(); // signal we are about to enter getToken() try { waiterResult.set(auth.getToken()); } catch (Throwable t) { @@ -1809,14 +1776,20 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except }, "oidc-getToken-waiter"); waiter.setDaemon(true); waiter.start(); - Assert.assertTrue("the waiter thread did not start", waiterStarted.await(10, TimeUnit.SECONDS)); + // 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 - proven by isAlive() (a fail-fast throw would have finished the - // thread), so this cannot pass merely because the waiter had not started yet + // 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", @@ -1856,12 +1829,8 @@ public void testGetTokenRefreshesWhenServedKindIsNullButRefreshTokenExists() thr }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { // groupsInToken=true - try { - auth.signIn(); - Assert.fail("signIn() must reject a grant with no id_token when groups are encoded in the token"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); - } + 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()); @@ -1892,12 +1861,7 @@ public void testGetTokenRefreshesWithoutPrompting() throws Exception { try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) { // before any sign-in, getToken() must not prompt - it throws - try { - auth.getToken(); - Assert.fail("expected getToken() to fail before sign-in"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("no token")); - } + assertOidcFails(auth::getToken, "no token", "expected getToken() to fail before sign-in"); // sign in once interactively Assert.assertEquals("ACCESS-1", auth.signIn()); expireCachedToken(auth); @@ -1906,12 +1870,8 @@ public void testGetTokenRefreshesWithoutPrompting() throws Exception { // now make the refresh fail; getToken() must throw, not start the device flow refreshOk.set(false); expireCachedToken(auth); - try { - auth.getToken(); - Assert.fail("expected getToken() to fail when the refresh is rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("interactive sign-in")); - } + 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()); @@ -1934,12 +1894,8 @@ public void testBlankServedTokenFromWireIsNotServed() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected signIn() to reject a blank served token from the wire"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("no access_token")); - } + assertOidcFails(auth::signIn, "no access_token", + "expected signIn() to reject a blank served token from the wire"); } }); } @@ -1992,12 +1948,7 @@ public void testGroupsInTokenButNoIdTokenFails() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); - } + assertOidcFails(auth::signIn, "no id_token"); } }); } @@ -2106,12 +2057,7 @@ public void testIncompleteDeviceResponseRejected() throws Exception { MockOidcServer.json(200, "{\"device_code\":\"DEV\",\"expires_in\":300,\"interval\":1}"); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete device authorization")); - } + assertOidcFails(auth::signIn, "incomplete device authorization"); } }); } @@ -2465,7 +2411,7 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc } @Test(timeout = 30_000) - public void testRejectedBuildDoesNotLeakNativeMemory() { + 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 @@ -2473,42 +2419,47 @@ public void testRejectedBuildDoesNotLeakNativeMemory() { // 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. Measure the parser tag directly; the module's assertMemoryLeak does not flag a single-tag growth. - 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)); + // 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() { + 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. - 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); + // 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)); + 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) @@ -2523,12 +2474,7 @@ public void testNoAccessTokenWhenGroupsDisabledFails() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("no access_token")); - } + assertOidcFails(auth::signIn, "no access_token"); } }); } @@ -2544,12 +2490,8 @@ public void testNonSuccessDeviceAuthorizationResponseRejected() throws Exception AtomicBoolean prompted = new AtomicBoolean(false); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, challenge -> prompted.set(true))) { - try { - auth.signIn(); - Assert.fail("expected the non-2xx device authorization response to be rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response from the device authorization endpoint")); - } + 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()); } }); @@ -2568,13 +2510,11 @@ public void testNullAccessTokenNotServedAsLiteralNull() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - String token = auth.signIn(); - Assert.fail("a JSON null access_token must not be served as the literal token \"null\" [got=" + token + "]"); - } catch (OidcAuthException e) { - // null is absent, so a 2xx with no token is a definitive but malformed answer - Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response")); - } + // 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\""); } }); } @@ -2639,16 +2579,11 @@ public void testOauthErrorMessageStripsBidiControls() throws Exception { MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"" + desc + "\"}"); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertEquals("access_denied", e.getOauthError()); - String msg = e.getMessage(); - assertNoUnsafeDisplayChars(msg); - Assert.assertTrue(msg, msg.contains("access_denied")); - Assert.assertTrue(msg, msg.contains("deniedreversedend")); // readable text survives, controls gone - } + 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 } }); } @@ -2663,16 +2598,11 @@ public void testOauthErrorMessageStripsControlChars() throws Exception { MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"" + desc + "\"}"); try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertEquals("access_denied", e.getOauthError()); - String msg = e.getMessage(); - assertNoControlChars(msg); - Assert.assertTrue(msg, msg.contains("access_denied")); - Assert.assertTrue(msg, msg.contains("FAKE: paste your token")); // readable text survives - } + 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 } }); } @@ -2777,12 +2707,7 @@ public void testPollIntervalClampedTo60() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, shown::set)) { - try { - auth.signIn(); - Assert.fail("expected the device code to expire"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); - } + assertOidcFails(auth::signIn, "device code expired", "expected the device code to expire"); Assert.assertEquals(60, shown.get().getIntervalSeconds()); } }); @@ -2826,13 +2751,9 @@ public void testRateLimitedTokenEndpointBacksOffInsteadOfFailingFast() throws Ex }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected the device code to expire while the token endpoint kept returning 429"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); - Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request")); - } + 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")); } }); } @@ -2883,13 +2804,9 @@ public void testPersistent5xxDuringPollingKeepsPollingToDeadline() throws Except }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected the device code to expire while the token endpoint returned 503"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); - Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request")); - } + 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")); } }); } @@ -2907,13 +2824,9 @@ public void testTerminal4xxDuringPollingFailsFast() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected a terminal 4xx to fail fast"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); - Assert.assertFalse(e.getMessage(), e.getMessage().contains("device code expired")); - } + 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")); } }); } @@ -3221,12 +3134,7 @@ public void testTimesOutWhenCodeExpires() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected a timeout"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); - } + assertOidcFails(auth::signIn, "timed out", "expected a timeout"); } }); } @@ -3294,14 +3202,9 @@ public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertFalse("the token must not leak into the message: " + e.getMessage(), - e.getMessage().contains(secret)); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpStatus=")); - } + OidcAuthException e = assertOidcFails(auth::signIn, "httpStatus="); + Assert.assertFalse("the token must not leak into the message: " + e.getMessage(), + e.getMessage().contains(secret)); } }); } @@ -3398,13 +3301,9 @@ public void testTokenUnderNonSuccessStatusIsNotAccepted() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected a token under a 400 to be rejected, not accepted"); - } catch (OidcAuthException e) { - Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED")); - Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); - } + 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")); } }); } @@ -3425,14 +3324,10 @@ public void testTokenWithControlCharsRejected() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected a token with control characters to be rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); - // the token bytes must never leak into the message - Assert.assertFalse(e.getMessage(), e.getMessage().contains("X-Injected")); - } + 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")); } }); } @@ -3453,14 +3348,10 @@ public void testTokenWithNonAsciiCharRejected() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected a token with a non-ASCII character to be rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); - // the token bytes must never leak into the message - Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-LEAK")); - } + 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")); } }); } @@ -3520,12 +3411,7 @@ public void testTruncatedTokenResponseRejected() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not parse")); - } + assertOidcFails(auth::signIn, "could not parse"); } }); } @@ -3542,12 +3428,7 @@ public void testUnexpectedTokenResponseRejected() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response")); - } + assertOidcFails(auth::signIn, "unexpected response"); } }); } @@ -3590,12 +3471,7 @@ public void testUseAfterCloseThrowsClearly() { .build() ) { auth.close(); - try { - auth.signIn(); - Assert.fail("expected signIn() after close() to be rejected"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("closed")); - } + assertOidcFails(auth::signIn, "closed", "expected signIn() after close() to be rejected"); try { auth.clearCache(); Assert.fail("expected clearCache() after close() to be rejected"); @@ -3662,12 +3538,7 @@ public void testWrongTokenKindDoesNotWedgeCache() throws Exception { }; try (MockOidcServer server = new MockOidcServer(handler); OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { - try { - auth.signIn(); - Assert.fail("expected an OidcAuthException on the first call"); - } catch (OidcAuthException e) { - Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); - } + 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()); @@ -3711,6 +3582,52 @@ private static void assertNoUnsafeDisplayChars(String value) { } } + /** + * 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\"," @@ -3872,6 +3789,16 @@ private static boolean invokeIsLoopbackHost(String host) throws Exception { return (boolean) m.invoke(null, host); } + 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 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 index d7fce50b2..4c87c18bf 100644 --- 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 @@ -32,20 +32,16 @@ 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 org.junit.Assert; import org.junit.Test; import java.io.IOException; -import java.io.OutputStream; import java.net.InetAddress; import java.net.ServerSocket; -import java.net.Socket; 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; @@ -245,58 +241,26 @@ public void testProviderTokenSentOnRealUpgrade() throws Exception { // 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. - List authHeaders = Collections.synchronizedList(new ArrayList<>()); - ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); - int port = listener.getLocalPort(); - byte[] respBytes = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - Thread serverThread = new Thread(() -> { - while (!listener.isClosed()) { - try { - Socket s = listener.accept(); - Thread handler = new Thread(() -> { - try (Socket sock = s) { - byte[] buf = new byte[8192]; - int n = sock.getInputStream().read(buf); - if (n < 0) { - return; - } - String request = new String(buf, 0, n, StandardCharsets.US_ASCII); - for (String line : request.split("\r\n")) { - if (line.regionMatches(true, 0, "Authorization:", 0, "Authorization:".length())) { - authHeaders.add(line.substring("Authorization:".length()).trim()); - } - } - OutputStream os = sock.getOutputStream(); - os.write(respBytes); - os.flush(); - } catch (Exception ignored) { - } - }, "qwp-token-upgrade-handler"); - handler.setDaemon(true); - handler.start(); - } catch (Exception ignored) { - return; - } - } - }, "qwp-token-upgrade-server"); - serverThread.setDaemon(true); - serverThread.start(); - - try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=127.0.0.1:" + port + ";failover=off;target=any;") - .withBearerTokenProvider(() -> "tok-0")) { + // 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 above + // but the upgrade request already carried the Bearer header captured below } - } finally { - listener.close(); - serverThread.join(500); + 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)); } - Assert.assertEquals("the provider's token must reach the real upgrade request", 1, authHeaders.size()); - Assert.assertEquals("Bearer tok-0", authHeaders.get(0)); }); } 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 36a24caa0..f33b69a5b 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 @@ -310,7 +310,12 @@ public void testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted() ScriptedFactory factory = ScriptedFactory .alwaysFailing(() -> new QwpAuthFailedException(401, "127.0.0.1", 9000)) .withDynamicCredential(); - long authDwellFloorMillis = 25L; + // 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()); @@ -666,11 +671,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(() -> { @@ -731,11 +737,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(() -> { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java index 3233ac4c1..2401bcfce 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java @@ -67,10 +67,10 @@ * the dead engine. * *

    The test injects an NPE into {@code ring.close()} by reflectively - * setting the engine's {@code ring} field to {@code null}. The current - * code propagates the NPE before reaching slotLock cleanup. After the - * fix (wrap the close steps in try/finally so slotLock.close() always - * runs), the slot is releasable by a fresh sender and the test goes green. + * setting the engine's {@code ring} field to {@code null}. A close that + * propagates that NPE before reaching slotLock cleanup is the regression + * this pins; the close steps run under try/finally so slotLock.close() + * always runs, which is what leaves the slot releasable by a fresh sender. * *

    The end-to-end signal is "can a fresh {@code SlotLock.acquire} on * the same slot dir succeed?" — the user-visible consequence of a leaked From 67542a2a304ac3afbd93cdced72d9b2507137bf8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 23:47:48 +0100 Subject: [PATCH 135/192] Bound the two lock waits on nanoTime, not the wall clock Both waits computed their deadline from System.currentTimeMillis(), which is adjustable. An NTP step or an operator setting the date back stretches such a deadline by the size of the jump, so each wait outlived the bound it documents by however far the clock moved: - acquireForGetToken() polls for the instance lock behind a peer's silent refresh, bounded at LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x httpTimeoutMillis precisely so the ILP flush path degrades to a retryable failure instead of stalling. A backward step stalled the producer thread instead - the one outcome the bound exists to prevent. - acquireLock() polls for the cross-process lock file, and inLock() promises to fall back to a lock-free refresh once lockAcquireBudgetMillis is spent (up to 30s configured). A backward step held a sign-in there past that budget rather than letting it proceed unserialized. Both now take their deadline from System.nanoTime(), and both compare by DIFFERENCE rather than by ordering so the arithmetic stays correct across nanoTime's wraparound. The tryLock slice goes with them, in nanoseconds. This makes the two consistent with the rest of the file rather than introducing a new idiom: discardBody, parseBody and the device-code poll loop already bound themselves with nanoTime, and these two were the outliers. Three wall-clock users nearby are deliberately left alone, none being an elapsed budget: the token expiry checks read an ABSOLUTE instant derived from the IdP's expires_in and persisted across restarts, where a nanoTime value would be meaningless; isOlderThan and sweepTempFiles compare against file mtimes, which are wall-clock and cross-process by definition; and the two back-off latches (isRefreshBackedOff, isStoreLoadBackedOff) can only be RELEASED early by a clock step, never extended, which is the safe direction and is already reasoned about where they are written - converting them would additionally need a new "never failed yet" sentinel, since both use 0 as that marker and nanoTime() may legitimately return 0. No test accompanies this. Observing the defect means stepping the machine's clock mid-wait, and neither class has a clock seam to inject; the bounds themselves stay pinned by testGetTokenDegradesWhenStoreLockHeld (the store budget is spent, and the call returns well inside it) and by the contended-getToken tests. Full client suite: 3327 tests, 0 failures, unchanged from before the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 10 ++++++++-- .../client/cutlass/auth/OidcDeviceAuth.java | 16 +++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) 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 index 2a55a4600..0cdf36194 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -939,7 +939,13 @@ private String acquireLock(Path lock) throws InterruptedException { // 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(); - final long deadline = System.currentTimeMillis() + lockAcquireBudgetMillis; + // 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 @@ -953,7 +959,7 @@ private String acquireLock(Path lock) throws InterruptedException { // 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.currentTimeMillis() >= deadline) { + 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 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 index 402b86335..d4ce2121e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1269,19 +1269,25 @@ private void acquireForGetToken() { // (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. - final long deadline = System.currentTimeMillis() - + (long) LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE * httpTimeoutMillis; + // 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 remaining = deadline - System.currentTimeMillis(); - if (remaining <= 0) { + 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(remaining, GET_TOKEN_LOCK_POLL_SLICE_MILLIS), TimeUnit.MILLISECONDS)) { + if (lock.tryLock(Math.min(remainingNanos, GET_TOKEN_LOCK_POLL_SLICE_MILLIS * 1_000_000L), TimeUnit.NANOSECONDS)) { return; } } catch (InterruptedException e) { From 002228ae360b884e34c3702ec7de0156da9beff5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 00:01:55 +0100 Subject: [PATCH 136/192] Make java.desktop optional so the module resolves without it DeviceCodePrompt.openBrowser() promises the browser launch is skipped "on a runtime without the java.desktop module" and never prevents sign-in, and BrowserLauncher is kept in its own class, referenced behind a LinkageError catch, precisely to keep that promise. module-info.java then undid it: a mandatory `requires java.desktop` is satisfied during module RESOLUTION, before a line of client code runs, so on a runtime image without java.desktop - a jlink image, a --limit-modules run - the JVM failed at startup with FindException and the catch never got the chance to degrade to "print the URL and carry on". 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 used as an explicit module is, which is what this changes: `requires static java.desktop` makes the dependency compile-time only, and java.desktop's absence arrives as the NoClassDefFoundError that catch already handles. java.awt.Desktop in BrowserLauncher is the module's only java.desktop reference, so nothing else moves. One consequence is documented rather than left to be discovered: a static requires is NOT followed during runtime resolution, so an application running this client as an explicit module gets the browser launch only when java.desktop is in its own graph anyway - it requires it, or the launch adds it. Class-path applications are unaffected, java.desktop being resolved by default there. That note is in the openBrowser() javadoc, the module-info comment and the README paragraph that makes the same promise to users. The new test runs a second JVM with the client on the MODULE path under --limit-modules io.questdb.client, which limits the universe to the client plus the closure of its MANDATORY requires. A static requires is not in that closure, so the child is genuinely desktop-free and proves three things: the module resolved without java.desktop, java.awt.Desktop really is unreachable, and the default prompt still prints the URL and code and returns. The middle check is the regression guard, and it was verified by putting the mandatory requires back: the closure then drags java.desktop into the universe, the child finds it reachable and exits non-zero, and the test fails with "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". The child costs ~150ms - the module graph is tiny. Both new files are Java 8 source with no java.lang.module API, confirmed under javac --release 8. That is load-bearing: the java8 release profile compiles this test tree (it excludes only module-info.java), so a ModuleLayer reference would have broken the release build. The test skips on a Java 8 runtime and when no module-info.class sits beside the client classes - the JDK 8 build, whose automatic module this defect never reached. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../client/cutlass/auth/DeviceCodePrompt.java | 8 ++ core/src/main/java/module-info.java | 13 +- .../auth/DesktopFreeModulePathTest.java | 122 ++++++++++++++++++ .../cutlass/auth/DesktopFreePromptMain.java | 79 ++++++++++++ 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeModulePathTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreePromptMain.java diff --git a/README.md b/README.md index bc122b157..3101c5e2b 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.c For a standalone sender, use `httpTokenProvider(auth::getToken)` for the same rotating-token behavior. A fixed `httpToken(token)` or `token=` connect-string value captures the token once, so a client that reconnects after that token expires starts failing authentication. Hand rotating credentials to the provider API, not a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. -By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: +By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in (the client declares `requires static java.desktop`, so the module is optional at run time and its absence can never break module resolution; a modular application therefore gets the browser launch only when `java.desktop` is in its own module graph) — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: ```java // print only, do not open a browser: 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 index 51914c403..314ddb748 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java @@ -66,6 +66,14 @@ public interface DeviceCodePrompt { * 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 */ 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/cutlass/auth/DesktopFreeModulePathTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeModulePathTest.java new file mode 100644 index 000000000..cce2310a1 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeModulePathTest.java @@ -0,0 +1,122 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 testTheModuleResolvesAndPromptsWithoutJavaDesktop() 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, + "--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 below is on the class path, so the client is not a root by default + "--add-modules", "io.questdb.client", + "-classpath", testClasses.getPath(), + DesktopFreePromptMain.class.getName()); + // deliberately NOT setting questdb.client.oidc.open.browser=false: the kill-switch returns before + // BrowserLauncher touches java.awt.Desktop, so a run with it set would never reach the LinkageError + // this exists to exercise. Nothing can pop a browser in a JVM with no java.desktop. + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = readFully(process.getInputStream()); + int exitCode = process.waitFor(); + + Assert.assertEquals("the desktop-free module-path run failed:\n" + output, 0, exitCode); + 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()); + } + + 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..92e02cc38 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreePromptMain.java @@ -0,0 +1,79 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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); + } +} From a4da03bec30b4c378702544254a5e7e19c0c992a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 00:04:53 +0100 Subject: [PATCH 137/192] Make the desktop-free child structurally unable to open a browser DesktopFreePromptMain is the only place in the suite that drives the real DeviceCodePrompt.openBrowser() with the questdb.client.oidc.open.browser kill-switch left enabled - it has to, because the kill-switch returns before BrowserLauncher touches java.awt.Desktop, so a run with it set would never reach the LinkageError the test exists to exercise. What stops a real browser opening is therefore the child's own precondition: it verifies java.awt.Desktop is unreachable before prompting. That precondition was correct but only by statement order - the prompt sat after the try/catch, reached by falling through the System.exit. It now runs INSIDE the catch arm that proved Desktop unreachable, so no later edit can reorder its way into a launch. The launching test also passes -Djava.awt.headless=true, an independent second net: Desktop.isDesktopSupported() answers false in headless mode, so even a JVM that somehow had java.desktop could not reach a browser. Neither change weakens the test - headless changes what Desktop ANSWERS, not whether the class reference links, and the mutation check still fails on a mandatory `requires java.desktop` with the same message. Prompted by two browser windows opening on the developer's machine during this work. Those came from my own throwaway probe commands, not from a test: while verifying the fix I ran the client's real prompt against a deliberately-rebuilt OLD descriptor (mandatory requires), where java.desktop IS present, and did not set the kill-switch on those two runs. The committed test never did it - its precondition exits first - but "never did it" resting on statement order is exactly the sort of thing worth nailing down. Co-Authored-By: Claude Opus 5 (1M context) --- .../auth/DesktopFreeModulePathTest.java | 8 ++++++- .../cutlass/auth/DesktopFreePromptMain.java | 23 +++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) 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 index cce2310a1..94f45477c 100644 --- 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 @@ -77,6 +77,11 @@ public void testTheModuleResolvesAndPromptsWithoutJavaDesktop() throws Exception ProcessBuilder pb = new ProcessBuilder( javaBin, + // Second net under the 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 the 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 @@ -88,7 +93,8 @@ public void testTheModuleResolvesAndPromptsWithoutJavaDesktop() throws Exception DesktopFreePromptMain.class.getName()); // deliberately NOT setting questdb.client.oidc.open.browser=false: the kill-switch returns before // BrowserLauncher touches java.awt.Desktop, so a run with it set would never reach the LinkageError - // this exists to exercise. Nothing can pop a browser in a JVM with no java.desktop. + // this exists to exercise. What keeps a browser from opening is the child's own precondition - it + // only runs the prompt in the arm that proved Desktop unreachable - plus the headless flag above. pb.redirectErrorStream(true); Process process = pb.start(); String output = readFully(process.getInputStream()); 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 index 92e02cc38..1248cd314 100644 --- 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 @@ -65,15 +65,20 @@ public static void main(String[] args) { + "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 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)); + // 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); + System.out.println(SUCCESS_MARKER); + } } } From 65f07331d2cfa1696a51650b733e09a2835be518 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 00:36:22 +0100 Subject: [PATCH 138/192] Stripe the in-process token store locks, unblock the lock stamp Two fixes in the same file: the one that was asked for, and one its test run exposed. 1. PROCESS_LOCKS grew permanently The in-process lock that serializes same-identity refreshes was a ConcurrentHashMap keyed on the identity fingerprint, and nothing ever removed an entry. TokenStoreKey is public and inLock() is public API, so how many identities a process mints is the caller's business - one per end user in a multi-tenant service is an ordinary shape - and each one permanently rooted a 64-char hash plus a lock. The comment claiming it was "bounded by identity count (a handful)" described the expected caller, not the data structure. It is now a fixed 64-entry stripe table, indexed by a spread of key.hash().hashCode() masked to the table size. Striping rather than reference-counted removal, because the lock only has to serialize AT LEAST every same-identity pair: two unrelated identities landing on one stripe merely wait for each other, which costs one of them the refresh round trip a same-identity peer would have cost anyway, while UNDER-serializing double-POSTs a rotating refresh token and gets the family revoked. Over-serializing is cheap and safe, under-serializing is neither, so the fixed table buys the bound for free and leaves no removal race to get wrong. 2. The lock stamp blocked the acquire for 3.2 seconds newLockNonce() built the stamp from ManagementFactory.getRuntimeMXBean().getName(), for the pid@host a human might read out of a stuck lock file. That call RESOLVES THE LOCAL HOSTNAME - VMManagementImpl.getVmId() calls InetAddress.getLocalHost() - and on a box with a cold mDNS cache it took 3162ms, 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. inLock() documents that it degrades to a lock-free refresh once lockAcquireBudgetMillis is spent; a debugging aid was breaking that promise. Once per JVM, too - the MXBean caches the name - so the first credential refresh in a process paid it and nothing later did, which is why it read as a rare flake rather than a bug. Found by testGetTokenDegradesWhenStoreLockHeld going red at 3405ms against its 2s bound. Instrumenting acquireLock reproduced it in roughly one run in twelve and located it exactly: DBG nonce parts: mxbean=3162ms millis=0ms uuid=0ms DBG file poll 3393ms budget=200 nonce=false Pre-existing, and independent of the striping above: the stall is inside newLockNonce(), the stripe wait never fired, and the poll loop striping does not touch. The stamp is now millis + UUID. Nothing reads the pid@host: releaseLock and stealIfStale compare stamps byte-wise against their own, and the frozen contract has each implementation check only its own stamp. Not even Compat.currentPid(), which SlotLock uses for this kind of diagnostic - its Java 9+ variant is a free ProcessHandle.current().pid(), but its Java 8 variant IS that same getName() call, and the bound has to hold on every runtime this artifact supports. design/oidc-token-persistence.md now makes pid@host explicitly optional and forbids obtaining it in any way that can block the acquire, noting that Python's socket.gethostname() is gethostname(2) and free where Java has no equivalent; the two stamp shapes interoperate because no implementation parses another's. Coverage: a test that the lock table is fixed, neither grows nor is rebuilt across 500 distinct identities, and that the index actually spreads (a broken mask would quietly serialize the whole process); and a direct unit-level guard that inLock spends its budget behind a live peer lock and returns inside it, so the next blocking call added to the acquire fails at the store rather than three layers up. The loop that reproduced the stall - 1 failure in 12 - is now 0 in 16. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 80 ++++++++++--- .../test/cutlass/auth/FileTokenStoreTest.java | 109 +++++++++++++++++- design/oidc-token-persistence.md | 10 +- 3 files changed, 174 insertions(+), 25 deletions(-) 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 index 0cdf36194..4f5bc5956 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -38,7 +38,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.lang.management.ManagementFactory; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; @@ -60,7 +59,6 @@ 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; @@ -147,17 +145,28 @@ public final class FileTokenStore implements TokenStore { // 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 (pid@host + millis + UUID) is a - // few hundred bytes, so anything past this cap is corrupt or hostile and is not read into memory + // 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, keyed on the identity fingerprint. 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. Bounded by identity - // count (a handful), so it never grows unbounded. - private static final ConcurrentHashMap PROCESS_LOCKS = new ConcurrentHashMap<>(); + // 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. + // + // A fixed STRIPE TABLE, not a map keyed on the identity fingerprint. 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 the + // ConcurrentHashMap this replaced rooted a 64-char hash plus a lock for every one + // of them, for the life of the JVM, with nothing ever removing an entry. Striping is the right trade + // rather than reference-counted removal, because the lock only has to serialize AT LEAST every + // same-identity pair: two unrelated identities that land on one stripe merely wait for each other, which + // costs one of them the refresh round trip a same-identity peer would have cost anyway, while + // UNDER-serializing double-POSTs a rotating refresh token. Over-serializing is cheap and safe, + // under-serializing is neither - so a fixed table buys the bound for free, and with no removal race to + // get wrong. Keep the length a power of two: processLockFor() masks rather than divides. + private static final ReentrantLock[] PROCESS_LOCKS = newProcessLockTable(64); // 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 @@ -322,7 +331,7 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { Thread.currentThread().interrupt(); return false; } - final ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(key.hash(), k -> new ReentrantLock()); + final ReentrantLock processLock = processLockFor(key); // 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 @@ -550,12 +559,36 @@ private static void deleteCapturedLock(Path captured) { } private static String newLockNonce() { - // a per-acquisition owner stamp: the pid@host and the acquire time are human-readable debugging aids, - // 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 - return ManagementFactory.getRuntimeMXBean().getName() // typically pid@host - + ' ' + System.currentTimeMillis() - + ' ' + UUID.randomUUID(); + // 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 ReentrantLock[] newProcessLockTable(int stripes) { + final ReentrantLock[] locks = new ReentrantLock[stripes]; + for (int i = 0; i < stripes; i++) { + locks[i] = new ReentrantLock(); + } + return locks; } private static boolean nullableEquals(String keyValue, StringSink fileValue) { @@ -700,6 +733,15 @@ private static void putStringMember(StringSink sink, String name, CharSequence v putString(sink, value); } + private static ReentrantLock processLockFor(TokenStoreKey key) { + // key.hash() is a hex SHA-256, so its bits are already uniform and String.hashCode inherits that; + // spread anyway - the standard HashMap mix - so a table this small never rides on the low bits alone. + // Masking with length-1 is why the length must stay a power of two, and it handles a negative + // hashCode (including Integer.MIN_VALUE) without the Math.abs trap. + final int h = key.hash().hashCode(); + return PROCESS_LOCKS[(h ^ (h >>> 16)) & (PROCESS_LOCKS.length - 1)]; + } + 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 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 index 92057f9b6..b40647de4 100644 --- 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 @@ -38,6 +38,7 @@ 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; @@ -52,10 +53,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -264,7 +268,7 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { // 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 ReentrantLock on key.hash() before any lock-file logic, and that map is + // in-process PROCESS_LOCKS stripe for key.hash() before any lock-file logic, and that table 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 @@ -372,6 +376,61 @@ public void testConcurrentStealersLeaveExactlyOneWinner() throws Exception { }); } + @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. The ConcurrentHashMap this replaced rooted a 64-char hash plus a lock for each of them, permanently: + // nothing removed an entry, and the comment claiming it was "bounded by identity count (a + // handful)" was a statement about the expected caller, not about the data structure. + Field field = FileTokenStore.class.getDeclaredField("PROCESS_LOCKS"); + field.setAccessible(true); + Object before = field.get(null); + Assert.assertTrue("the in-process locks must be a FIXED table, not a per-identity map: " + before, + before instanceof ReentrantLock[]); + final int stripes = ((ReentrantLock[]) before).length; + + Path dir = storeDir(); + Files.createDirectories(dir); + FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000); + AtomicInteger ran = new AtomicInteger(); + // far more identities than stripes, so a map-backed implementation would visibly outgrow the table + for (int i = 0; i < 500; 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(); + return true; + })); + } + Assert.assertEquals("every identity must still have run its critical section", 500, ran.get()); + + Object after = field.get(null); + Assert.assertSame("the stripe table must not be rebuilt", before, after); + Assert.assertEquals("the stripe table must not grow with the identity count", + stripes, ((ReentrantLock[]) after).length); + // and it must be usable, not merely present: a fresh identity still serializes + Assert.assertTrue(store.inLock(sampleKey(), () -> true)); + + // The index must actually spread. Nothing about correctness rests on it - landing every identity + // on one stripe still serializes same-identity pairs, which is all the lock owes anyone - but it + // would quietly serialize every unrelated identity in the process behind one another's refresh + // round trips, so a broken mask (an & 0, a constant) is worth catching here rather than in + // production latency. + Method processLockFor = FileTokenStore.class.getDeclaredMethod("processLockFor", TokenStoreKey.class); + processLockFor.setAccessible(true); + Set distinct = new HashSet<>(); + for (int i = 0; i < 500; i++) { + distinct.add((ReentrantLock) processLockFor.invoke(null, + new TokenStoreKey("client-" + i, "https://idp.example.com:443/token", + "https://idp.example.com:443/device", "openid", null, false))); + } + Assert.assertTrue("500 identities must not all collide onto one stripe (got " + distinct.size() + + " of " + stripes + ")", distinct.size() > stripes / 2); + }); + } + @Test public void testReplaceTargetGivesUpAfterTheRetryBudget() throws Exception { Assume.assumeTrue("POSIX permissions are needed to deny the rename", @@ -478,8 +537,8 @@ public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exc 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 - // ReentrantLock, which inLock() takes on key.hash() BEFORE any file-lock logic - so it would hold + // asserted below (overlaps==0, maxInside==1) is provided by the in-process PROCESS_LOCKS stripe, + // 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 @@ -880,6 +939,46 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { }); } + @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(); + Files.createDirectories(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(() -> { @@ -1275,8 +1374,8 @@ public void testLockFilePermissionsOwnerOnly() throws Exception { FileTokenStore store = new FileTokenStore(dir); TokenStoreKey key = sampleKey(); Path lock = lockFile(dir, key); - // the lock file is created owner-only too: it briefly records pid@host and sits beside the 0600 - // token file, so it must not widen the directory's exposure. Assert while the lock is held; inLock + // 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 { diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index d6e0e3c2e..cb2e8c49c 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -449,7 +449,15 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i / `open(p,"x")`) is a plain filesystem primitive that interoperates trivially. The contract mandates the lock-file scheme; OS advisory locks are out. - **Lock file:** `.lock` beside the token file, containing a unique per-acquisition - owner stamp — the holder's `pid@host`, a creation timestamp, and a random nonce. + owner stamp — a creation timestamp, a random nonce, and OPTIONALLY the holder's `pid@host`. + The nonce alone carries the uniqueness the protocol needs; `pid@host` is a debugging aid, and a + client MUST NOT obtain it in any way that can block the acquire. Python's `socket.gethostname()` + is `gethostname(2)` and is free, so the Python client includes it; Java has no cheap equivalent — + `ManagementFactory.getRuntimeMXBean().getName()` resolves the local hostname through + `InetAddress.getLocalHost()`, measured at 3.2s on a host with a cold mDNS cache, inside a 200ms + acquire budget and on a producer's flush path — so the Java client omits it rather than break the + bound below. Since no implementation parses another's stamp (see "Release verifies ownership"), + the two shapes interoperate unchanged. Acquire by an exclusive-create (`O_CREAT|O_EXCL`) and write the owner stamp through that same open handle — see the empty-lock note below for the window this leaves; on contention, spin with short backoff up to a small acquire budget (~3s); if it still cannot be acquired, From 5c117577f866e9d608b7cc898ac9b983202b2429 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 00:54:09 +0100 Subject: [PATCH 139/192] Pin what five regression tests only appeared to test Test-code only; no product change. Each of these passed against a product that had lost the behaviour the test is named for. The browser kill-switch was the clearest case, and it is measured rather than argued: deleting the questdb.client.oidc.open.browser gate from BrowserLauncher.open() leaves all four BrowserLauncherTest tests green. The property READ is observable; what open() does with it is not, because on a headless JVM open() is a no-op either way. It becomes observable where java.desktop does not exist - reaching Desktop throws - so DesktopFreeKillSwitchMain drives both directions inside the module-path child JVM that already exists for the desktop-free prompt test: kill-switch off must return quietly, kill-switch on must produce a LinkageError. The second half is what makes the first half mean anything; without it an open() that returned early for any reason would look like a working gate. The same mutation now fails with "open() must return at the kill-switch, before java.awt.Desktop". BrowserLauncherTest states its scope and names where the rest is proven. The silent-refresh contention test discarded its refresher's outcome (catch (Throwable ignore), "irrelevant to this test"). It is not irrelevant: that thread is the one that holds the lock and produces ACCESS-2. On a run where it failed, the waiter simply refreshed for itself and still saw ACCESS-2, so every assertion held while the contention the test exists for never happened. Its result and any throwable are now captured and asserted. The file-store contention tests joined workers with a bare join() and let their throwables die on the worker thread. A wedged contender therefore stalled the suite to the 20-minute surefire timeout with no failing assertion, and a contender that THREW left the exclusion counters looking like a clean run. Both are fixed: a joinOrFail helper bounds every join and asserts the thread finished, and the first worker throwable is carried back to the test thread. Two entry latches went with them. Both counted down at the top of the thread body - before the call they gated - so they proved only that the thread had been scheduled, and in the process-lock test that was a false green: an interrupt landing before entry becomes a CARRIED flag, which inLock answers by returning false without entering the wait, and all four assertions pass on a path the test does not mean to exercise. The waiter's own stack is now polled for the frame that matters (acquireLock for the file-lock poll, inLock for the process lock). MockOidcServer had no test of its own. The property every OIDC suite leans on is not that it serves JSON - that fails loudly - but that a handler failure REACHES THE TEST THREAD: handlers run on daemon connection threads where an uncaught throwable is otherwise just a dropped connection, which most of these tests tolerate, so a broken assertion inside a handler reads as a pass. MockOidcServerTest throws an AssertionError from a handler and requires close() to resurface that same instance, with a healthy-run control so an unconditionally-rethrowing close() cannot pass both. BackgroundDrainerDurableAckRetryTest had 27 unbounded @Test methods, all of which spawn or drive a drainer; each now carries timeout = 60_000, about ten times the whole class's runtime. On the other half of that finding - that its dynamic-auth tests stub hasDynamicCredential() rather than exercising the real FixedAuthHeader classifier - no duplicate was added, because the classifier IS pinned: WebSocketTokenProviderTest.testCredentialKindTaggedForTheOrphanDrainerTerminalPolicy asserts it on a real built sender for httpToken, httpUsernamePassword, httpTokenProvider and no credential at all, read both directly and through the background reconnect factory a drainer is handed - the exact seam BackgroundDrainer consumes. The drainer tests stub it because what they pin is the terminal policy each verdict produces, which is the right split. What was missing is that neither half named the other, so deleting one would quietly turn a verdict into an assumption; both now say so. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/auth/BrowserLauncherTest.java | 13 +- .../auth/DesktopFreeKillSwitchMain.java | 99 +++++++++++++++ .../auth/DesktopFreeModulePathTest.java | 67 ++++++---- .../test/cutlass/auth/FileTokenStoreTest.java | 115 +++++++++++++---- .../test/cutlass/auth/MockOidcServerTest.java | 117 ++++++++++++++++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 16 ++- .../client/WebSocketTokenProviderTest.java | 5 + .../BackgroundDrainerDurableAckRetryTest.java | 65 ++++++---- 8 files changed, 416 insertions(+), 81 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/DesktopFreeKillSwitchMain.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServerTest.java 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 index dfcfa6494..97beca964 100644 --- 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 @@ -55,10 +55,15 @@ public void testOpenIsBestEffortForRejectedUrls() throws Exception { @Test public void testOpenRespectsDisableProperty() throws Exception { - // the kill-switch itself is asserted via isBrowserOpenEnabled() - a real browser launch is - // unobservable (no-op on a headless JVM either way), so asserting the property read directly is the - // only way to prove the gate actually flips. A VALID http(s) URL confirms the no-op under "false" - // below is the kill-switch, not URL rejection; this gate also keeps the suite from popping a browser. + // 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"; 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 index 94f45477c..5ee97eee8 100644 --- 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 @@ -61,8 +61,41 @@ */ 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"))); @@ -77,43 +110,31 @@ public void testTheModuleResolvesAndPromptsWithoutJavaDesktop() throws Exception ProcessBuilder pb = new ProcessBuilder( javaBin, - // Second net under the child's own desktop-free check, and independent of it: should + // 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 the test - - // headless changes what Desktop ANSWERS, not whether the class reference links. + // 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 below is on the class path, so the client is not a root by default + // 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(), - DesktopFreePromptMain.class.getName()); - // deliberately NOT setting questdb.client.oidc.open.browser=false: the kill-switch returns before + 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 - // this exists to exercise. What keeps a browser from opening is the child's own precondition - it - // only runs the prompt in the arm that proved Desktop unreachable - plus the headless flag above. + // 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 failed:\n" + output, 0, exitCode); - 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()); + 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 { 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 index b40647de4..2599955a4 100644 --- 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 @@ -296,19 +296,30 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { 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(() -> store.inLock(key, section)); + 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) { - t.join(); + 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 @@ -366,7 +377,7 @@ public void testConcurrentStealersLeaveExactlyOneWinner() throws Exception { t.start(); } for (Thread t : ts) { - t.join(); + joinOrFail(t, "a stealer"); } Assert.assertNull("a stealer failed outright: " + failure.get(), failure.get()); @@ -563,20 +574,31 @@ public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exc 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(() -> store.inLock(key, section)); + 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) { - t.join(); + 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()); @@ -850,26 +872,38 @@ public void testInLockAbandonsFileLockWaitOnInterrupt() throws Exception { AtomicBoolean ran = new AtomicBoolean(); AtomicReference result = new AtomicReference<>(); + AtomicReference waiterError = new AtomicReference<>(); AtomicBoolean flagLeftSet = new AtomicBoolean(); - CountDownLatch entered = new CountDownLatch(1); Thread waiter = new Thread(() -> { - entered.countDown(); - result.set(store.inLock(key, () -> { - ran.set(true); - return true; - })); - flagLeftSet.set(Thread.currentThread().isInterrupted()); + 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(); - Assert.assertTrue(entered.await(5, TimeUnit.SECONDS)); - Thread.sleep(200); // let it settle into the poll loop + // 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()); @@ -909,24 +943,33 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { AtomicBoolean ran = new AtomicBoolean(); AtomicReference result = new AtomicReference<>(); - CountDownLatch entered = new CountDownLatch(1); + AtomicReference waiterError = new AtomicReference<>(); Thread waiter = new Thread(() -> { - entered.countDown(); - result.set(waiterStore.inLock(key, () -> { - ran.set(true); - return true; - })); + try { + result.set(waiterStore.inLock(key, () -> { + ran.set(true); + return true; + })); + } 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(); - Assert.assertTrue(entered.await(5, TimeUnit.SECONDS)); - Thread.sleep(200); // let it settle onto the process lock + // 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); @@ -1108,8 +1151,8 @@ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception { }); tA.start(); tB.start(); - tA.join(); - tB.join(); + 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 @@ -1796,6 +1839,30 @@ private void assertNoCaptureTempFiles(Path dir, TokenStoreKey key) throws Except } } + 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() + ']'); + } + + 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"); } 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/OidcDeviceAuthTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java index efd2f157f..1921d77d8 100644 --- 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 @@ -1753,11 +1753,17 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except .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 { - auth.getToken(); - } catch (Throwable ignore) { - // the refresh completes once released; a late error here is irrelevant to this test + refresherResult.set(auth.getToken()); + } catch (Throwable t) { + refresherError.set(t); } }, "oidc-silent-refresh"); refresher.setDaemon(true); @@ -1800,6 +1806,10 @@ public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Except 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()); 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 index e4fb3701a..2e6d0d512 100644 --- 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 @@ -78,6 +78,11 @@ public void testCredentialKindTaggedForTheOrphanDrainerTerminalPolicy() throws E // 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(); 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 f33b69a5b..65dedc955 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 @@ -125,7 +125,7 @@ public void tearDown() { Files.remove(slotPath); } - @Test + @Test(timeout = 60_000) public void testCallbackArgumentsCarrySlotPathAndAttemptNumber() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -144,7 +144,7 @@ public void testCallbackArgumentsCarrySlotPathAndAttemptNumber() throws Exceptio }); } - @Test + @Test(timeout = 60_000) public void testEscalatesAfterMaxAttemptsAndDropsSentinel() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -170,7 +170,7 @@ public void testEscalatesAfterMaxAttemptsAndDropsSentinel() throws Exception { }); } - @Test + @Test(timeout = 60_000) public void testListenerThrowingOnPersistentFailureStillMarksFailed() throws Exception { assertMemoryLeak(() -> { BackgroundDrainerListener throwing = new BackgroundDrainerListener() { @@ -196,7 +196,7 @@ public void onDurableAckUnavailable(String slotPath, int attemptNumber) { }); } - @Test + @Test(timeout = 60_000) public void testListenerThrowingOnUnavailableContinuesRetrying() throws Exception { assertMemoryLeak(() -> { AtomicInteger unavailableCalls = new AtomicInteger(); @@ -225,7 +225,7 @@ public void onDurableAckUnavailable(String slotPath, int attemptNumber) { }); } - @Test + @Test(timeout = 60_000) public void testNoListenerNoNullPointerOnEscalation() throws Exception { assertMemoryLeak(() -> { ScriptedFactory factory = ScriptedFactory.alwaysFailing( @@ -239,7 +239,7 @@ public void testNoListenerNoNullPointerOnEscalation() throws Exception { }); } - @Test + @Test(timeout = 60_000) public void testTerminalUpgradeMarksFailedImmediately() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -277,7 +277,7 @@ 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 @@ -301,7 +301,7 @@ public void testFixedCredentialAuthRejectionStillQuarantinesImmediately() throws }); } - @Test + @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 @@ -338,7 +338,7 @@ public void testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted() }); } - @Test + @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. @@ -364,7 +364,7 @@ public void testRotatingCredentialAuthRejectionRidesPastAttemptThresholdBeforeDw }); } - @Test + @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 @@ -391,7 +391,7 @@ public void testRotatingCredentialAuthRejectionRidesOutBoundedBudget() throws Ex }); } - @Test + @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 @@ -546,7 +546,7 @@ public WebSocketClient reconnect() { }); } - @Test + @Test(timeout = 60_000) public void testReturnsClientOnSuccessFirstAttempt() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -563,7 +563,7 @@ public void testReturnsClientOnSuccessFirstAttempt() throws Exception { }); } - @Test + @Test(timeout = 60_000) public void testRetriesOnDurableAckMismatchThenSucceeds() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -585,7 +585,7 @@ public void testRetriesOnDurableAckMismatchThenSucceeds() throws Exception { }); } - @Test + @Test(timeout = 60_000) public void testStopRequestedDuringRetryAbortsWithStoppedOutcome() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -620,7 +620,7 @@ public void testStopRequestedDuringRetryAbortsWithStoppedOutcome() throws Except }); } - @Test + @Test(timeout = 60_000) public void testWallTimeBudgetEscalatesBeforeAttemptCap() throws Exception { assertMemoryLeak(() -> { CountingListener listener = new CountingListener(); @@ -653,7 +653,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 @@ -725,7 +725,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, @@ -788,7 +788,7 @@ public void testTransportErrorNeverQuarantinesInvariantB() throws Exception { }); } - @Test + @Test(timeout = 60_000) public void testJvmErrorEscapesConnectRetryLoop() throws Exception { assertMemoryLeak(() -> { // Regression (M3): catch (Throwable) in connectWithDurableAckRetry used @@ -820,7 +820,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), @@ -867,7 +867,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 @@ -910,7 +910,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 @@ -956,7 +956,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 @@ -994,7 +994,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 @@ -1020,7 +1020,7 @@ public void testSaturatingCapabilityGapBudgetDoesNotQuarantineOnTheFirstSweep() }); } - @Test + @Test(timeout = 60_000) public void testTransportErrorResetsCapabilityGapEpisode() throws Exception { assertMemoryLeak(() -> { // A transport state breaks a consecutive capability-gap episode. @@ -1053,7 +1053,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. @@ -1098,7 +1098,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 @@ -1157,7 +1157,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 @@ -1334,6 +1334,17 @@ 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; From 94adf954f5be1e451a452242dca8fb64ac02db98 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 01:08:12 +0100 Subject: [PATCH 140/192] State the token store's real single-login and integrity limits Two persistence claims promised more than the store delivers, and both are the kind a reader would design against. "tokens for different servers or identities never collide" reads as a per-person partition. TokenStoreKey hashes 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. A store holds one active login. The README now says that, and points a caller who needs several at once at separate stores (FileTokenStore.at on a per-user directory, or a per-user questdb.client.oidc.token.store.dir) rather than at the hashed name. The default location is per OS user already, so the case that bites is one OS user - a shared service account, a process signing in on behalf of several people. "a tampered entry is ignored" promises authentication the file does not have. There is no MAC or signature over its contents, so anyone able to WRITE it can substitute a well-formed entry that the client adopts and presents: the permissions are the control, not the format. What the load path rejects 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. Each of those is already pinned by a test, so the replacement text describes behaviour that is proven rather than asserted. Corrected in two more places carrying the same words, since the wording is the defect rather than the file it sits in: FileTokenStore's class javadoc ("one plaintext JSON file per identity ... several identities coexist"), which now carries both boundaries as named paragraphs; and design/oidc-token-persistence.md, the frozen cross-language contract, whose "lets several identities coexist (multiple servers / users on one host)" is the exact misreading. That one also now REQUIRES a client to document the limit, because the Python client inherits the same key shape and would otherwise inherit the same promise. testOneConfigurationHoldsOneActiveLogin pins the half that was only prose: two identically configured keys hash the same, the second save wins the entry, and a separate store directory is what actually separates two logins. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +- .../client/cutlass/auth/FileTokenStore.java | 22 +++++++++-- .../test/cutlass/auth/FileTokenStoreTest.java | 37 +++++++++++++++++++ design/oidc-token-persistence.md | 10 ++++- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3101c5e2b..9bbeff33f 100644 --- a/README.md +++ b/README.md @@ -475,9 +475,9 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( } ``` -`FileTokenStore.atDefaultLocation()` writes one file per identity 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, so tokens for different servers or identities never collide. 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. +`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. -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 — a tampered, corrupt, oversized, or identity-mismatched entry is ignored (the client falls back to a refresh or an interactive sign-in), and a token carrying control or non-ASCII characters is never placed on the wire. +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: an entry read from a directory other local users can write is discarded and the directory tightened back to `0700`. 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). 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 index 4f5bc5956..c2ca3d300 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -63,17 +63,33 @@ import java.util.concurrent.locks.ReentrantLock; /** - * The default {@link TokenStore}: one plaintext JSON file per identity under a directory, with the - * refresh token protected at rest by file permissions (0600 file, 0700 directory) rather than by + * 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 identities coexist and the name leaks neither the + * {@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. 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 index 2599955a4..46f095ef1 100644 --- 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 @@ -442,6 +442,43 @@ public void testProcessLocksDoNotGrowWithTheIdentityCount() throws Exception { }); } + @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", diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index cb2e8c49c..c52b6a899 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -176,9 +176,15 @@ Convenience: `FileTokenStore.atDefaultLocation()` and `FileTokenStore.at(Path di - **Location.** `${questdb.client.oidc.token.store.dir}` if set, else `${user.home}/.questdb/oidc-tokens/`. The `questdb.client.oidc.*` system-property namespace already exists (`questdb.client.oidc.open.browser`), so this matches. -- **One file per identity**, named `.json`. A hashed name avoids +- **One file per CONFIGURATION**, named `.json`. A hashed name avoids leaking the endpoint/client id/scope through directory listings, and lets several - identities coexist (multiple servers / users on one host). + configurations coexist (multiple servers or providers on one host). The key names a + configuration, not a subject — no field of it identifies the human who signed in — so two + people using the same configuration address the same file and the later sign-in overwrites + the earlier: **one store holds one active login**. A client MUST document that, and point a + caller who needs several concurrent logins at separate store directories rather than letting + them read the hashed name as a per-user partition. The default location is per OS user + already, so the case that bites is one OS user signing in as several people. - **Permissions.** Directory created `rwx------` (0700), file `rw-------` (0600), set *at creation* via `PosixFilePermissions.asFileAttribute(...)` so there is no world-readable window. On a non-POSIX FS (`setPosixFilePermissions`/attribute throws From 90e49e19d268fd1ac0b318c9cb64e5adcc550316 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 01:15:59 +0100 Subject: [PATCH 141/192] Drop the credentials on close instead of keeping them close() disabled every token operation and then went on holding everything those operations had touched: the served token and the refresh token in their String fields, and the raw grant in the sinks that carried it. Nothing can reach any of it through the API afterwards - the closed flag refuses signIn(), getToken() and clearCache() - so it is retention with no purpose, for the life of the instance. The sinks were the larger half, and the one a plain null would not have fixed. formSink holds the last request body, which on the refresh path is literally "refresh_token="; the two response parsers hold every field of the last response, device code and user code included. All three are REUSED, and clear() only rewinds the write position - so a long secret followed by a short write stays perfectly legible in the tail. Closing that needs a primitive the sink itself has to provide, since nothing outside StringSink can reach past the position: StringSink.wipe() overwrites the whole backing array and empties the sink. close() now runs that sweep BEFORE the frees. Nulling a String and overwriting a char[] cannot throw, whereas an HttpClient close conceivably can, so doing it first means a failing free cannot leave a refresh token sitting in the instance; the existing lexer-before-clients ordering is unchanged. clearCache() runs the same sweep - it already nulled the token fields, which is exactly the half that is not enough for a caller whose intent is to forget a credential. Best-effort, and the code says where the effort stops: a String already handed to a caller lives until the GC takes it, and the HTTP client's native receive buffers - where the same bytes passed - are returned to the allocator unzeroed. A caller who needs more than this should not be holding tokens in this process. StringSinkWipeTest demonstrates the retention rather than asserting it: subSequence() reads the backing array rather than the write position, so the test shows the secret surviving clear() and then gone after wipe(), and that the sink stays usable afterwards. testCloseWipesCredentialState signs in and then spends the refresh token, so the grant passes through both parsers and the form body, asserts the state is genuinely present BEFORE the close - otherwise the sweep proves nothing - and then walks the instance reflectively: every declared field, one level into the objects they point at, reading each sink's WHOLE backing array for any of eight distinctive secrets. Generic on purpose, so a sink added to this class or either parser later is covered without anyone remembering to extend the test. Verified by removing the sweep from close(): "the served access token must not survive close() expected null, but was:". Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 61 +++++++- .../io/questdb/client/std/str/StringSink.java | 15 ++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 135 ++++++++++++++++++ .../test/std/str/StringSinkWipeTest.java | 79 ++++++++++ 4 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java 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 index d4ce2121e..096776cc0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -452,12 +452,11 @@ public void clearCache() { lock.lock(); try { throwIfClosed(); - accessToken = null; - idToken = null; - refreshToken = null; + // 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; - lastPersistedRefreshToken = null; refreshFailedAtMillis = 0; if (tokenStore != null) { try { @@ -500,6 +499,11 @@ public void close() { 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) @@ -1988,6 +1992,32 @@ private boolean tryRefreshCoordinated() { } } + 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. + // + // 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(); + } + 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 @@ -2275,6 +2305,18 @@ private static final class DeviceAuthorizationResponseParser implements JsonPars 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(); @@ -2641,6 +2683,17 @@ private static final class TokenResponseParser implements JsonParser, Mutable { 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(); 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..5492342cf 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; @@ -120,6 +122,19 @@ public Utf16Sink put(char c) { /* Either IDEA or FireBug complain, annotation galore */ @NotNull + /** + * 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. + */ + public void wipe() { + Arrays.fill(buffer, (char) 0); + pos = 0; + } + @Override public String toString() { return new String(buffer, 0, pos); 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 index 1921d77d8..4ec139f42 100644 --- 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 @@ -44,6 +44,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.net.InetAddress; import java.net.ServerSocket; import java.util.concurrent.CountDownLatch; @@ -1136,6 +1137,66 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { }); } + @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 @@ -3569,6 +3630,22 @@ private static void assertBuildFails(String deviceEndpoint, String tokenEndpoint } } + /** + * 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)); + } + 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))); @@ -3776,6 +3853,12 @@ static void expireCachedToken(OidcDeviceAuth auth) throws Exception { } // 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); @@ -3785,6 +3868,48 @@ private static long readExpiresAtMillis(OidcDeviceAuth auth) throws Exception { // 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; + } + private static boolean invokeIsEndpointUnderIssuerPath(String endpointUrl, String issuer) throws Exception { Method m = OidcDeviceAuth.class.getDeclaredMethod("isEndpointUnderIssuerPath", String.class, String.class); m.setAccessible(true); @@ -3842,6 +3967,16 @@ private static void parseSplitValue(int cacheSizeLimit, long address, int split, } } + /** + * The sink's WHOLE backing array as a String - past the write position too, which is where a cleared but + * unwiped secret survives. + */ + 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\":{"); diff --git a/core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java b/core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java new file mode 100644 index 000000000..8b2c172cc --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java @@ -0,0 +1,79 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.std.str; + +import io.questdb.client.std.str.StringSink; +import org.junit.Assert; +import org.junit.Test; + +/** + * Covers {@link StringSink#wipe()}, the hygiene primitive the OIDC client uses to stop a token remaining + * legible in a reusable sink after the instance that read it is closed. + */ +public class StringSinkWipeTest { + + @Test + public void testClearLeavesTheTailLegibleAndWipeDoesNot() { + // subSequence() reads the backing array directly rather than the write position, so it can see what + // clear() left behind - which is exactly the retention wipe() exists to close, demonstrated here + // rather than asserted. + StringSink sink = new StringSink(); + sink.put("REFRESH-TOKEN-abcdef0123456789"); + final int held = sink.length(); + sink.clear(); + sink.put("ok"); // a short write after a long secret: the position rewinds, the characters do not + + Assert.assertEquals("ok", sink.toString()); + Assert.assertTrue("clear() only rewinds, so the tail is still readable: " + sink.subSequence(0, held), + sink.subSequence(0, held).toString().contains("TOKEN-abcdef")); + + sink.wipe(); + + Assert.assertEquals(0, sink.length()); + Assert.assertEquals("", sink.toString()); + Assert.assertFalse("wipe() must overwrite the whole buffer, not just rewind: " + + sink.subSequence(0, held), + sink.subSequence(0, held).toString().contains("TOKEN")); + } + + @Test + public void testWipeLeavesTheSinkUsable() { + // it is a hygiene step, not a teardown: the OIDC client wipes on clearCache() and keeps going + StringSink sink = new StringSink(); + sink.put("secret"); + sink.wipe(); + sink.put("reused"); + Assert.assertEquals("reused", sink.toString()); + Assert.assertEquals(6, sink.length()); + } + + @Test + public void testWipeOfAnEmptySinkIsANoOp() { + StringSink sink = new StringSink(); + sink.wipe(); + Assert.assertEquals(0, sink.length()); + Assert.assertEquals("", sink.toString()); + } +} From ce9c14277d9013cdb0611aab47d7658d598fa4f7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 01:22:13 +0100 Subject: [PATCH 142/192] Validate a row once per row, not twice Both at() overloads, in the V1 and V2 HTTP senders, ran validateRowStarted() and then called atNow(), which ran it again on the identical state. Nothing between the two calls can change that state - only table(), a column write and the row terminator touch it, and in between sits a buffer write - so every explicit-timestamp row paid for a second switch on the hot ingestion path. The first check is the load-bearing one and stays where it is: at() must validate BEFORE it writes the timestamp, or a rejected row leaves those bytes in the request buffer for the next row to inherit, splicing a stray timestamp into an otherwise valid line. So atNow() gives up its second half instead: it is now validateRowStarted() plus terminateRow(), and the four at() overloads call terminateRow() directly. The rationale - and why at() cannot simply delegate to atNow() - lives on terminateRow(), where the next person to touch this will be standing. Behaviour is unchanged in every state. The TCP senders look similar but are not affected: they validate once, inside atNow(), after the write. On coverage, the no-table-name half was already pinned across both overloads and both versions by LineHttpSenderTokenProviderTest.testAtWithoutTableDoesNotCorruptTheAuthorizationHeader, which also proves the no-stray-bytes property end to end: the token still reaches the wire as its own header afterwards. The other rejected state - a table with no symbols or columns - had no coverage from at() at all, so testRejectedExplicitTimestampWritesNothing now drives both states, both overloads and both versions through bufferView(): a rejected at() must not grow the buffer by a byte, and the half-built row must still complete afterwards. Verified by swapping V1 to write-then-validate: "a rejected at() must not write a timestamp [version=1 overload=0] expected:<0> but was:<20>". Co-Authored-By: Claude Opus 5 (1M context) --- .../line/http/AbstractLineHttpSender.java | 25 ++++++-- .../cutlass/line/http/LineHttpSenderV1.java | 10 ++-- .../cutlass/line/http/LineHttpSenderV2.java | 10 ++-- .../line/LineHttpSenderInterfaceTest.java | 59 +++++++++++++++++++ 4 files changed, 91 insertions(+), 13 deletions(-) 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 1a639b085..8099785d1 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 @@ -470,11 +470,7 @@ public void atNow() { // validateRowStarted() rejects EMPTY and TABLE_NAME_SET, so only ADDING_SYMBOLS and ADDING_COLUMNS // reach the terminator write validateRowStarted(); - request.put('\n'); - state = RequestState.EMPTY; - if (rowAdded()) { - flush(); - } + terminateRow(); } @Override @@ -1036,6 +1032,25 @@ protected void validateColumnName(CharSequence name) { * (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. */ + /** + * 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(); + } + } + protected void validateRowStarted() { switch (state) { case EMPTY: 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 f13b01a8f..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,18 +116,20 @@ protected LineHttpSenderV1(ObjList hosts, @Override public void at(long timestamp, ChronoUnit unit) { - // reject before the first write, not in atNow() after it: see validateRowStarted() + // 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) { - // reject before the first write, not in atNow() after it: see validateRowStarted() + // 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 7116f8b37..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,20 +163,22 @@ protected LineHttpSenderV2( @Override public void at(long timestamp, ChronoUnit unit) { - // reject before the first write, not in atNow() after it: see validateRowStarted() + // 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) { - // reject before the first write, not in atNow() after it: see validateRowStarted() + // 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/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)); + } + } } From a230dfc658a67b4a8500230e688506a4eaa17fb4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 01:44:04 +0100 Subject: [PATCH 143/192] Tidy four things the auth tests were getting away with Test-code only; no product change. The browser kill-switch was set in a static initializer by three classes and never put back. Surefire runs the module in one JVM, so whichever of them loaded first flipped questdb.client.oidc.open.browser for every class after it, and a test meaning to exercise the DEFAULT quietly ran against someone else's override, decided by class-load order. A shared NoBrowserLaunch @ClassRule saves the previous value and restores - or clears - it afterwards, so the window is exactly the class that needs it. Two reflective helpers had a real public path and are gone. isLoopbackHost becomes testPlaintextIdpEndpointIsAllowedOnlyForLoopbackHosts: the same 27 host forms driven through build(), which does no network I/O. Every loopback form is accepted and every other one is rejected with "use an https url" - I probed each row before writing the assertions rather than assuming the split was clean - and the two forms that never reach the classifier at all, an empty host and an IPv6 literal, are asserted against the parser's own messages so the table is not silently credited with covering them. isEndpointUnderIssuerPath becomes a pair of tables driven through fromQuestDB against a MockOidcServer whose /settings advertises the hostile endpoint, the same shape the five sibling scenario tests already use; each case requires the specific "not under the pinned issuer" message, so a rejection for an unrelated reason cannot pass for one. Both now assert the outcome a caller sees and survive the private helper being renamed or inlined. BrowserLauncherTest keeps its reflection: the class and all three methods are package-private, and the only public route - DeviceCodePrompt.openBrowser().promptUser(...) - swallows everything by contract, so there is no public path to assert on. Its javadoc now says that, and names the child-JVM test that pins the one externally observable behaviour. Five classes each built a java.io.tmpdir + nanoTime path and carried their own recursive delete - rmDirRec, rmDirRecursive, rmDir, plus an inline directory walk. All five use the TemporaryFolder rule this branch's auth tests already standardised on; the bespoke helpers are deleted, and the one @After doing more than deleting keeps only that other work. DirectUtf8SinkTest claimed put(src, 2, 5) "grows the sink past its initial capacity". It does not: DirectByteSink's native create allocates a MINIMUM of 32 bytes however small a capacity it is asked for, so three bytes into a DirectUtf8Sink(4) never reallocates and the growth path was untested. The comment is corrected and testPutByteArrayRangeGrowsTheSink seeds a few bytes, copies a range comfortably past the floor, requires the payload to survive the move contiguous and in order, and keeps appending so growth is exercised past the first reallocation. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/auth/BrowserLauncherTest.java | 12 ++ .../auth/OidcDeviceAuthPersistenceTest.java | 8 +- .../test/cutlass/auth/OidcDeviceAuthTest.java | 192 +++++++++++------- .../WebSocketCredentialCancellationTest.java | 8 +- ...oundDrainerCredentialOutageReportTest.java | 36 +--- .../BackgroundDrainerDurableAckRetryTest.java | 30 +-- ...ckgroundDrainerMidDrainAuthRejectTest.java | 36 +--- .../EngineCloseSlotLockReleaseTest.java | 43 +--- .../impl/SenderPoolSfTokenProviderTest.java | 40 +--- .../test/std/str/DirectUtf8SinkTest.java | 41 +++- .../client/test/tools/NoBrowserLaunch.java | 66 ++++++ 11 files changed, 283 insertions(+), 229 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/tools/NoBrowserLaunch.java 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 index 97beca964..3fc59d6dd 100644 --- 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 @@ -30,6 +30,18 @@ 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 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 index 9e73feb28..78bba4140 100644 --- 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 @@ -30,8 +30,10 @@ 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; @@ -47,9 +49,9 @@ public class OidcDeviceAuthPersistenceTest { private static final String DEVICE_PATH = "/device"; private static final String TOKEN_PATH = "/token"; - static { - System.setProperty("questdb.client.oidc.open.browser", "false"); - } + // 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(); 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 index 4ec139f42..e6c2a4a2c 100644 --- 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 @@ -37,13 +37,14 @@ 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.Method; import java.lang.reflect.Modifier; import java.net.InetAddress; import java.net.ServerSocket; @@ -59,12 +60,13 @@ public class OidcDeviceAuthTest { - static { - // The default device-code prompt opens a browser when one is available. Developer machines have - // one, so disable the launch process-wide for the whole test class; otherwise every flow that - // reaches the prompt (e.g. a fromQuestDB or builder test) would pop a real browser tab. - System.setProperty("questdb.client.oidc.open.browser", "false"); - } + /** + * 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) -> { @@ -2326,44 +2328,36 @@ public void testIssuerPathScopingRejectsSiblingRealm() throws Exception { @Test(timeout = 30_000) public void testIssuerPathScopingRejectsRawDotSegments() throws Exception { - // a RAW (unencoded) ".." or "." path segment carries no '%' or '\' (so endpointPathHasEncodedSeparator - // passes it) and no '?'/'#'/control (so Endpoint.parse accepts it), yet a lenient server normalizes - // .../realms/acme/../evil/token to a different realm - so the segment scan in isEndpointUnderIssuerPath - // must reject a bare '.'/'..' segment. Every OTHER traversal test feeds a percent-encoded or '#'/'?' - // form caught by an earlier gate; only these bare-dot cases exercise that dot-segment loop. - String issuer = "https://idp.example.com/realms/acme"; - // control: a genuine sub-path endpoint stays accepted (proves the check is not rejecting everything) - Assert.assertTrue(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/token", issuer)); - // a parent-traversal segment escapes the issuer path once the server normalizes it -> rejected - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/../evil/token", issuer)); - // a single-dot segment and a mix are likewise normalized away and must not slip the scan - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/./../evil/token", issuer)); - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/./acme/token", issuer)); + 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 { - // hardening: an encoded path separator can hide behind a SPLIT encoding (%2%66 -> %2f -> '/') or a - // double encoding (%252f), and a literal backslash is folded to '/' by decodePathSegments. Each lets an - // extra segment masquerade as being under the issuer path while a different raw path travels on the - // wire, so isEndpointUnderIssuerPath must reject them. Only the path is scoped; the origin matches here. - String issuer = "https://idp.example.com/realms/acme"; - // a genuine sub-path endpoint stays accepted - Assert.assertTrue(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/protocol/token", issuer)); - // split, double, and literal-backslash separators all resolve to a deeper /realms/acme/evil and are rejected - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%2%66evil/token", issuer)); - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%252fevil/token", issuer)); - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme\\evil/token", issuer)); - // a percent-encoded backslash (%5c / %5C) is the encoded form of the literal '\' above; the scan - // rejects it at the encoded level too, before decodePathSegments would fold it to '/' - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5cevil/token", issuer)); - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5Cevil/token", issuer)); - // overlong-UTF-8 (%c0%ae, %e0%80%ae) and an IIS-style %u002e encode a '.'/'/' that a permissive server - // resolves but a byte-oriented percent decode leaves as high bytes or literal text; sitting past the - // issuer prefix they would slip the '..'/segment scan, so any '%' in an endpoint path is rejected - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%c0%ae%c0%ae/evil/token", issuer)); - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%e0%80%ae%e0%80%ae/evil/token", issuer)); - Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%u002e%u002e/evil/token", issuer)); + 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) @@ -2395,35 +2389,32 @@ public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exc } @Test(timeout = 30_000) - public void testLoopbackHostClassifierAcceptsLoopbackForms() throws Exception { - // localhost (any case) and the whole 127.0.0.0/8 block are loopback: a plaintext /settings fetch to - // them never leaves the host, so settingsChannelIsPlaintext correctly skips the plaintext-channel - // pin. This is the pin's only exercised exemption, since MockOidcServer binds to loopback. + 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 s : loopback) { - Assert.assertTrue("expected loopback: [" + s + "]", invokeIsLoopbackHost(s)); + 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); + } } - // The name is now 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, and this - // exemption is what lets a device code and a refresh token travel in cleartext. The assertions above - // therefore also pin that the resolution path still accepts the normal case - break it and every - // loopback form spelt as a name fails closed, which would be safe but would refuse a working local - // dev setup. The hostile half (localhost resolving OFF loopback) cannot be reached from a test - // without rewriting the host's resolver, so it is unasserted by design rather than by omission. - Assert.assertFalse("a name that does not resolve must fail closed", - invokeIsLoopbackHost("no-such-host.invalid")); - } - - @Test(timeout = 30_000) - public void testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing() throws Exception { - // every other host must classify as non-loopback so the plaintext-channel MITM pin FIRES over http - - // the firing path the loopback-bound test mock cannot reach end to end. A classifier that accepted - // any of these as loopback would silently disable the pin for a tampered /settings endpoint. + + // 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 = { - null, "", "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 @@ -2436,11 +2427,23 @@ public void testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing() throws Exc "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", "::1" + "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 s : notLoopback) { - Assert.assertFalse("expected non-loopback: [" + s + "]", invokeIsLoopbackHost(s)); + 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) @@ -3646,6 +3649,49 @@ private static void assertHoldsSomewhere(Object instance, String secret) throws 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))); @@ -3910,20 +3956,8 @@ && sinkContents((StringSink) nestedValue).contains(secret)) { return null; } - private static boolean invokeIsEndpointUnderIssuerPath(String endpointUrl, String issuer) throws Exception { - Method m = OidcDeviceAuth.class.getDeclaredMethod("isEndpointUnderIssuerPath", String.class, String.class); - m.setAccessible(true); - return (boolean) m.invoke(null, endpointUrl, issuer); - } - // 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 invokeIsLoopbackHost(String host) throws Exception { - Method m = OidcDeviceAuth.class.getDeclaredMethod("isLoopbackHost", String.class); - m.setAccessible(true); - return (boolean) m.invoke(null, host); - } - private static boolean isInside(Thread t, String method) { for (StackTraceElement frame : t.getStackTrace()) { if (OidcDeviceAuth.class.getName().equals(frame.getClassName()) 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 index fa4d552e3..82cf08ea0 100644 --- 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 @@ -30,6 +30,7 @@ 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; @@ -37,6 +38,7 @@ 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; @@ -82,9 +84,9 @@ public class WebSocketCredentialCancellationTest { private static final long SEED_TTL_MILLIS = 20_000L; private static final String TOKEN_PATH = "/token"; - static { - System.setProperty("questdb.client.oidc.open.browser", "false"); - } + // 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(); 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 index e6585c6f6..8da33aa1f 100644 --- 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 @@ -37,15 +37,15 @@ 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.After; 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.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -101,17 +101,17 @@ public class BackgroundDrainerCredentialOutageReportTest { 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-drainer-credential-" + System.nanoTime()).toString(); + slotPath = temp.getRoot().toPath().resolve("slot").toString(); assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT)); } - @After - public void tearDown() { - rmDirRec(slotPath); - } @Test public void testInitialConnectCredentialOutageIsNamedNotMislabelledUnreachable() throws Exception { @@ -270,26 +270,6 @@ private BackgroundDrainer newDrainer(ScriptedWireFactory factory) { /* durableAckKeepaliveIntervalMillis */ 200L); } - private static void rmDirRec(String dir) { - if (dir == null || !Files.exists(dir)) return; - long find = Files.findFirst(dir); - if (find > 0) { - try { - int rc = 1; - while (rc > 0) { - String name = Files.utf8ToString(Files.findName(find)); - if (name != null && !".".equals(name) && !"..".equals(name)) { - String child = dir + "/" + name; - if (!Files.remove(child)) rmDirRec(child); - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(dir); - } private static void runToCompletion(BackgroundDrainer drainer) throws InterruptedException { Thread t = new Thread(drainer, "test-credential-outage-drainer"); 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 65dedc955..42f492aee 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 @@ -43,7 +43,9 @@ 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; @@ -93,10 +95,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)); } @@ -104,25 +110,9 @@ 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(timeout = 60_000) 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 index 6bcf92edb..cd8d01c54 100644 --- 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 @@ -37,15 +37,15 @@ 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.After; 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.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -95,17 +95,17 @@ public class BackgroundDrainerMidDrainAuthRejectTest { 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-mid-drain-auth-" + System.nanoTime()).toString(); + slotPath = temp.getRoot().toPath().resolve("slot").toString(); assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT)); } - @After - public void tearDown() { - rmDirRec(slotPath); - } @Test public void testMidDrainConstantCredential401QuarantinesImmediately() throws Exception { @@ -214,26 +214,6 @@ private BackgroundDrainer newDrainer(ScriptedWireFactory factory) { /* durableAckKeepaliveIntervalMillis */ 200L); } - private static void rmDirRec(String dir) { - if (dir == null || !Files.exists(dir)) return; - long find = Files.findFirst(dir); - if (find > 0) { - try { - int rc = 1; - while (rc > 0) { - String name = Files.utf8ToString(Files.findName(find)); - if (name != null && !".".equals(name) && !"..".equals(name)) { - String child = dir + "/" + name; - if (!Files.remove(child)) rmDirRec(child); - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(dir); - } private static void runToCompletion(BackgroundDrainer drainer) throws InterruptedException { Thread t = new Thread(drainer, "test-mid-drain-auth-drainer"); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java index 2401bcfce..0c78e7777 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java @@ -32,14 +32,14 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock; import io.questdb.client.std.Files; import io.questdb.client.test.tools.TestUtils; -import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import java.lang.reflect.Field; import java.net.InetAddress; import java.net.ServerSocket; -import java.nio.file.Paths; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -80,45 +80,18 @@ public class EngineCloseSlotLockReleaseTest { private String sfDir; + // 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() { - sfDir = Paths.get(System.getProperty("java.io.tmpdir"), - "qdb-engine-close-leak-" + System.nanoTime()).toString(); + sfDir = temp.getRoot().toPath().resolve("slot").toString(); assertEquals(0, Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT)); } - @After - public void tearDown() { - if (sfDir == null) return; - rmDirRecursive(sfDir); - } - private static void rmDirRecursive(String dir) { - if (!Files.exists(dir)) return; - long find = Files.findFirst(dir); - if (find > 0) { - try { - int rc = 1; - while (rc > 0) { - String name = Files.utf8ToString(Files.findName(find)); - if (name != null && !".".equals(name) && !"..".equals(name)) { - String child = dir + "/" + name; - long probe = Files.findFirst(child); - if (probe > 0) { - Files.findClose(probe); - rmDirRecursive(child); - } else { - Files.remove(child); - } - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(dir); - } /** * A close driven by a caller that HOLDS the logical slot lock must not unlink it. diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java index d769214aa..a36bc9f16 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java @@ -30,15 +30,15 @@ import io.questdb.client.std.Files; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import io.questdb.client.test.tools.TestUtils; -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.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.nio.file.Paths; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -76,16 +76,16 @@ public class SenderPoolSfTokenProviderTest { private String sfDir; + // 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() { - sfDir = Paths.get(System.getProperty("java.io.tmpdir"), - "qdb-sf-pool-token-" + System.nanoTime()).toString(); + sfDir = temp.getRoot().toPath().resolve("slot").toString(); } - @After - public void tearDown() { - rmDir(sfDir); - } @Test public void testSfPooledSendersCarryTheProviderToken() throws Exception { @@ -247,30 +247,6 @@ private static boolean hasSegmentFile(String slotPath) { return false; } - private static void rmDir(String dir) { - if (dir == null || !Files.exists(dir)) { - return; - } - long find = Files.findFirst(dir); - if (find > 0) { - try { - int rc = 1; - while (rc > 0) { - String name = Files.utf8ToString(Files.findName(find)); - if (name != null && !".".equals(name) && !"..".equals(name)) { - String child = dir + "/" + name; - if (!Files.remove(child)) { - rmDir(child); - } - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(dir); - } private static final class CountingAckHandler implements TestWebSocketServer.WebSocketServerHandler { final AtomicInteger frames = new AtomicInteger(); diff --git a/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java b/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java index 1e1dbd15e..ad8860284 100644 --- a/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java +++ b/core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java @@ -39,6 +39,10 @@ public class DirectUtf8SinkTest extends AbstractTest { + // DirectByteSink.implCreate allocates at least this much however small a capacity it is asked for, so a + // test that means to exercise growth has to write past it + private static final int MIN_ALLOCATED_CAPACITY = 32; + @Test public void testAsAsciiCharSequence() { try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) { @@ -126,7 +130,7 @@ public void testPutByteArrayRange() { try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) { final byte[] src = "abcdefgh".getBytes(StandardCharsets.UTF_8); - // a partial range [2, 5) copies exactly "cde" and grows the sink past its initial capacity + // a partial range [2, 5) copies exactly "cde" sink.put(src, 2, 5); Assert.assertEquals(3, sink.size()); TestUtils.assertEquals("cde".getBytes(StandardCharsets.UTF_8), sink); @@ -146,6 +150,41 @@ public void testPutByteArrayRange() { } } + @Test + public void testPutByteArrayRangeGrowsTheSink() { + // DirectByteSink's native create allocates a MINIMUM of 32 bytes however small a capacity it is + // asked for, so a handful of bytes into a new DirectUtf8Sink(4) never reallocates - the sibling test + // above used to claim it did. Cross the floor for real: a range longer than 32 bytes must reallocate + // mid-copy, and the whole payload must survive that move, contiguous and in order. + final byte[] src = new byte[MIN_ALLOCATED_CAPACITY * 4]; + for (int i = 0; i < src.length; i++) { + src[i] = (byte) ('a' + (i % 26)); + } + try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) { + // seed a few bytes first, so the growing copy has existing content to preserve rather than + // starting from an empty sink + sink.put(src, 0, 3); + final int lo = 3; + final int hi = lo + MIN_ALLOCATED_CAPACITY + 17; // comfortably past the floor, and not a round number + sink.put(src, lo, hi); + + Assert.assertTrue("preconditions: the payload must exceed the 32-byte floor", + sink.size() > MIN_ALLOCATED_CAPACITY); + Assert.assertEquals(3 + (hi - lo), sink.size()); + final byte[] expected = new byte[3 + (hi - lo)]; + System.arraycopy(src, 0, expected, 0, 3); + System.arraycopy(src, lo, expected, 3, hi - lo); + TestUtils.assertEquals(expected, sink); + + // and it keeps growing across repeated appends, not just the first reallocation + for (int i = 0; i < 8; i++) { + sink.put(src, 0, src.length); + } + Assert.assertEquals(3 + (hi - lo) + 8 * src.length, sink.size()); + Assert.assertEquals((byte) src[0], sink.byteAt(3 + (hi - lo))); + } + } + @Test public void testPutByteArrayRangeRejectsBadBounds() { try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) { diff --git a/core/src/test/java/io/questdb/client/test/tools/NoBrowserLaunch.java b/core/src/test/java/io/questdb/client/test/tools/NoBrowserLaunch.java new file mode 100644 index 000000000..d9a381f7f --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/tools/NoBrowserLaunch.java @@ -0,0 +1,66 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.tools; + +import org.junit.rules.ExternalResource; + +/** + * Disables the OIDC device-flow browser launch for one test class, and puts the property back afterwards. + *

    + * The default {@code DeviceCodePrompt} opens a browser when one is available, which a developer machine has, + * so any test reaching the prompt would pop a real tab. Setting + * {@code questdb.client.oidc.open.browser=false} in a static initializer stopped that, but surefire runs the + * whole module in one JVM: whichever class loaded first flipped the property for every class after it, and + * nothing ever put it back. A test that means to exercise the DEFAULT - the launch enabled - then silently + * ran against someone else's override, depending on class-load order. + *

    + * Use as a class rule, so the window is exactly the class that needs it: + *

    + * @ClassRule
    + * public static final NoBrowserLaunch NO_BROWSER = new NoBrowserLaunch();
    + * 
    + */ +public final class NoBrowserLaunch extends ExternalResource { + + private static final String PROPERTY = "questdb.client.oidc.open.browser"; + private String previous; + private boolean wasSet; + + @Override + protected void after() { + if (wasSet) { + System.setProperty(PROPERTY, previous); + } else { + System.clearProperty(PROPERTY); + } + } + + @Override + protected void before() { + previous = System.getProperty(PROPERTY); + wasSet = previous != null; + System.setProperty(PROPERTY, "false"); + } +} From 0e4c762b4e44931643a276e837d71a4cf9466671 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 03:03:51 +0100 Subject: [PATCH 144/192] Arm the refresh back-off only on a real attempt getToken() stamped refreshFailedAtMillis from its own `if (refreshToken != null)` block, outside the short-circuit that decides whether a refresh runs at all. So a call the back-off itself skipped still re-armed the latch, and isRefreshBackedOff() measures elapsed time from that stamp: the five-second window slid forward by one call every time. It expired only if two consecutive getToken() calls were at least MIN_REFRESH_RETRY_INTERVAL_MILLIS apart. Nothing that produces data calls it that slowly. getToken() runs once per ILP flush and the default auto-flush interval is one second, so a single transient refresh failure - one IdP 5xx, one dropped connection - wedged the sender for the life of the process. Every later sender.table() threw "the cached token expired and could not be refreshed without an interactive sign-in", long after the provider recovered, and only signIn(), clearCache() or a restart cleared it. That is a circuit breaker, which is precisely what the field's javadoc says it is not. Arm the latch inside the attempted branch instead, so only a refresh that actually ran and failed re-arms it. maybeLoadFromStore() already arms its sibling back-off inside the catch for the same reason. The existing testGetTokenBacksOffAfterAFailedRefreshInsteadOfFlooding- TheIdp could not see this: it runs 25 calls in a loop that finishes in milliseconds and asserts one round trip, which "backs off for 5s" and "never retries again" satisfy identically. The new test drives the other half - one 503, then a healthy provider, then getToken() every 50ms, the cadence of a flushing producer - and asserts both that it recovers and that exactly one retry round trip happened. It has to spend real wall clock, because both shapes serve the same token and throttle to one round trip inside the window; back-dating the stamp reflectively would make the broken shape pass too. Counterfactual, with the production hunk reverted and the test kept: Tests run: 3, Failures: 1 testGetTokenBackOffExpiresWhileAProducerKeepsCalling the back-off must expire on its own; the identity provider recovered and getToken() never retried it expected: but was: One of three, not three of three - the two pre-existing back-off tests stay green, so the new one covers ground neither of them reached. Full suite on JDK 25: 3340 tests, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 18 ++++-- .../auth/OidcDeviceAuthPersistenceTest.java | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) 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 index 096776cc0..e847bc256 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -599,11 +599,19 @@ public String getToken() { 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"); } - if (refreshToken != null && !isRefreshBackedOff() && tryRefreshCoordinated()) { - refreshFailedAtMillis = 0; - return selectToken(); - } - if (refreshToken != null) { + // 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(); + } refreshFailedAtMillis = System.currentTimeMillis(); } if (cachedToken != null) { 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 index 78bba4140..b6e88f5ea 100644 --- 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 @@ -404,6 +404,69 @@ public void testGetTokenBacksOffAfterAFailedRefreshInsteadOfFloodingTheIdp() thr }); } + @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 testSignInClearsTheRefreshBackOff() throws Exception { assertMemoryLeak(() -> { From c19e03766ba7d5d3be27cee615b059c3ff805cd1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 03:34:44 +0100 Subject: [PATCH 145/192] End the settle budget when the wire delivers The two per-drain escalation counters had no notion of progress, so they spanned every session of a drain instead of the consecutive sweeps both terminals are documented to count. run() re-enters connectWithDurableAckRetry() after each mid-drain recoverable terminal, and the counters reset only in the three failure arms - rotating-401 retry, role reject, transport. Nothing reset them on a successful connect, and nothing reset them on a durable ack. So a drain that meets a gap window, connects, DELIVERS, and then meets a second window accumulates both windows toward one threshold. Sixteen sweeps that were never consecutive drop a .failed sentinel and report DATA_LOSS - and nothing in production clears that sentinel, so the replayable rows are abandoned for good. That is precisely the rolling upgrade the settle budget exists to ride out, reaching the opposite verdict, and it needs no OIDC and no token provider: the counter is incremented in the QwpDurableAckMismatchException arm, so any store-and-forward producer with request_durable_ack on is exposed. Track the ack watermark and end the episode when it advances. A durable ack past the watermark is the cluster accepting this drainer, which is stronger evidence than the role reject and the transport error that already reset these counters. Seed the watermark from what a previous run had acked, so only acks this drain earns count. Deliberately not called from 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. A flap that delivers nothing never advances the watermark and still escalates on schedule - testFlappingCredentialEscalatesAcrossMidDrainRecycles drives exactly that shape, calls connectWithDurableAckRetry() directly with no engine, and stays green. The new test scripts two nine-sweep windows - eighteen cumulative, past the threshold of sixteen, never more than nine in a row - with a session that durably acks between them. Generalising the two existing helpers was enough to express it: the handler now takes which connection drops after which seq, and the factory an arbitrary set of gap attempts rather than one contiguous window. Counterfactual, with only the noteAckProgress call removed: Tests run: 4, Failures: 1 testDeliveringBetweenTwoGapWindowsGrantsAFreshSettleBudget delivering between the windows ends the episode, so neither window reaches the threshold and the slot must still drain [attempts=20] expected: but was: One of four - the three pre-existing tests in the class still pass, so the new one covers ground none of them reached. Also restores the javadoc the counter block displaced from ackedFsn. Full suite on JDK 25: 3341 tests, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 63 +++++++++-- ...roundDrainerMidDrainCapabilityGapTest.java | 102 ++++++++++++++++-- 2 files changed, 144 insertions(+), 21 deletions(-) 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 c693d4b25..924feb1a3 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 @@ -156,25 +156,37 @@ public final class BackgroundDrainer implements Runnable { private final long sfMaxTotalBytes; private final String slotPath; private final long syncIntervalNanos; - /** Latest known {@code engine.ackedFsn()}; published for visibility. */ /** - * Escalation counters for the two bounded ride-outs, held per DRAIN rather than per call. + * 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. *

    - * They 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. These three measure the whole - * drain, which is the span a quarantine decision is about. + * 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; /** * Engine constructed by {@link #run()}, captured for test observation @@ -811,6 +823,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(); @@ -961,6 +998,11 @@ 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 @@ -1029,6 +1071,7 @@ public void run() { while (!stopRequestedOrInterrupted()) { long acked = engine.ackedFsn(); + noteAckProgress(acked); this.ackedFsn = acked; if (acked >= target) { outcome = DrainOutcome.SUCCESS; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java index 889fd3e5f..0e7268dfe 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java @@ -138,6 +138,66 @@ public void testMidDrainCapabilityGapGetsSettleBudgetNotQuarantine() throws Exce }); } + @Test + public void testDeliveringBetweenTwoGapWindowsGrantsAFreshSettleBudget() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // The settle budget counts CONSECUTIVE capability-gap sweeps. Held per drain with no notion + // of progress, it instead spans every session: two gap windows with a DELIVERING session + // between them accumulate toward one threshold, so 16 sweeps that were never consecutive + // quarantine a slot the cluster is still draining - and nothing in production clears the + // .failed sentinel, so those replayable rows are abandoned for good. That is the rolling + // upgrade this budget exists to ride out, reaching the opposite verdict. + // + // Two windows of 9 (18 cumulative, past the threshold of 16), never more than 9 in a row, + // with a session that durably acks between them. + final int windowLength = BackgroundDrainer.DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS - 7; + final int firstGapFrom = 2; + final int firstGapTo = firstGapFrom + windowLength - 1; // 2..10 + final int deliveringAttempt = firstGapTo + 1; // 11 + final int secondGapFrom = deliveringAttempt + 1; // 12 + final int secondGapTo = secondGapFrom + windowLength - 1; // 20 + assertTrue("the two windows must exceed the threshold that only consecutive sweeps may reach", + 2 * windowLength >= BackgroundDrainer.DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS); + assertTrue("neither window may reach it on its own", + windowLength < BackgroundDrainer.DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS); + + seedSlot(SEEDED_FRAMES); + // Connection 1 acks frame 0 then drops; connection 2 - the delivering session between the + // windows - acks frames 0 and 1, advancing the watermark, then drops too. + java.util.Map drops = new java.util.HashMap<>(); + drops.put(1, 0L); + drops.put(2, 1L); + try (TestWebSocketServer server = new TestWebSocketServer(new GapScenarioHandler(drops), true)) { + server.start(); + assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + ScriptedWireFactory factory = new ScriptedWireFactory(server.getPort(), + n -> (n >= firstGapFrom && n <= firstGapTo) + || (n >= secondGapFrom && n <= secondGapTo)); + BackgroundDrainer drainer = newDrainer(factory); + CountingListener listener = new CountingListener(); + drainer.setListener(listener); + + runToCompletion(drainer); + + assertEquals("delivering between the windows ends the episode, so neither window reaches " + + "the threshold and the slot must still drain [attempts=" + + factory.attempts() + "]", + BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome()); + assertFalse("a slot the cluster is still draining must not be quarantined", + Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + assertEquals("no persistent-failure escalation may be reported", 0, + listener.persistentFailures.get()); + assertTrue("both gap windows must actually have been driven [attempts=" + + factory.attempts() + "]", factory.attempts() > secondGapTo); + // The observability callback fires per gap sweep and must show the counter restarting + // rather than running on to the threshold. + assertTrue("the second window must restart the count, not continue the first " + + "[unavailableAttempts=" + listener.unavailableAttempts + "]", + java.util.Collections.max(listener.unavailableAttempts) <= windowLength); + } + }); + } + @Test public void testMidDrainPersistentCapabilityGapExhaustsBudgetThenQuarantines() throws Exception { TestUtils.assertMemoryLeak(() -> { @@ -300,13 +360,22 @@ public synchronized void onDurableAckUnavailable(String slotPath, int attemptNum */ private static final class GapScenarioHandler implements TestWebSocketServer.WebSocketServerHandler { private static final String TABLE = "trades"; - private final boolean dropFirstConnection; private final List arrivalOrder = new ArrayList<>(); + // connection index (1-based, in arrival order) -> the last per-connection seq it acks before + // closing the wire. A connection absent from the map acks everything it is sent. + private final java.util.Map dropAfterSeqByConnection; private final java.util.Map wireSeqByConn = new java.util.IdentityHashMap<>(); GapScenarioHandler(boolean dropFirstConnection) { - this.dropFirstConnection = dropFirstConnection; + this.dropAfterSeqByConnection = new java.util.HashMap<>(); + if (dropFirstConnection) { + this.dropAfterSeqByConnection.put(1, 0L); + } + } + + GapScenarioHandler(java.util.Map dropAfterSeqByConnection) { + this.dropAfterSeqByConnection = dropAfterSeqByConnection; } @Override @@ -320,14 +389,15 @@ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler clien int connectionIndex = arrivalOrder.indexOf(client) + 1; long seq = counter[0]++; try { - if (dropFirstConnection && connectionIndex == 1) { - if (seq == 0) { + Long dropAfterSeq = dropAfterSeqByConnection.get(connectionIndex); + if (dropAfterSeq != null) { + if (seq <= dropAfterSeq) { client.sendBinary(okFrame(seq, seq)); client.sendBinary(durableAckFrame(seq)); - } else if (seq == 1) { + } else if (seq == dropAfterSeq + 1) { client.close(); // mid-drain wire drop } - // seq > 1: late buffered frames from the condemned + // beyond that: late buffered frames from the condemned // connection; ignore. } else { client.sendBinary(okFrame(seq, seq)); @@ -374,10 +444,9 @@ private static byte[] okFrame(long wireSeq, long seqTxn) { */ private static final class ScriptedWireFactory implements CursorWebSocketSendLoop.ReconnectFactory { private final AtomicInteger calls = new AtomicInteger(); + private final java.util.function.IntPredicate isGapAttempt; private final int port; private final ThrowableSupplier throwSupplier; - private final int throwFrom; - private final int throwTo; ScriptedWireFactory(int port, int throwFrom, int throwTo) { this(port, throwFrom, throwTo, @@ -385,9 +454,20 @@ private static final class ScriptedWireFactory implements CursorWebSocketSendLoo } ScriptedWireFactory(int port, int throwFrom, int throwTo, ThrowableSupplier throwSupplier) { + this(port, n -> n >= throwFrom && n <= throwTo, throwSupplier); + } + + // General form: an arbitrary set of gap attempts, so a scenario can script more than one + // contiguous window and put a delivering session between them. + ScriptedWireFactory(int port, java.util.function.IntPredicate isGapAttempt) { + this(port, isGapAttempt, + () -> new QwpDurableAckMismatchException("localhost", port, "primary")); + } + + ScriptedWireFactory(int port, java.util.function.IntPredicate isGapAttempt, + ThrowableSupplier throwSupplier) { this.port = port; - this.throwFrom = throwFrom; - this.throwTo = throwTo; + this.isGapAttempt = isGapAttempt; this.throwSupplier = throwSupplier; } @@ -398,7 +478,7 @@ int attempts() { @Override public WebSocketClient reconnect() throws Exception { int n = calls.incrementAndGet(); - if (n >= throwFrom && n <= throwTo) { + if (isGapAttempt.test(n)) { Throwable t = throwSupplier.get(); if (t instanceof RuntimeException) throw (RuntimeException) t; if (t instanceof Exception) throw (Exception) t; From 5533480da0a430c976f01d0fb8399a6e73d91625 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 03:51:02 +0100 Subject: [PATCH 146/192] Create the store fixture dir owner-only, not by umask FileTokenStoreTest built the store directory with a bare Files.createDirectories(dir) at 24 sites, so its permissions came from whatever umask ran the build. At 002 - the default on the Linux CI agents - the directory arrives rwxrwxr-x, and load() reads a group-writable store directory as one another local user could have planted an entry in: it discards the entry and returns null BEFORE it opens the file. That is correct production behaviour, and the same check has its own deterministic coverage in testLoadRejectsAndDiscardsAnEntryFromAWorldWritableDirectory. The defect is the fixture, and it broke the class two ways. testFrozenSchemaEndpointsCarryAnExplicitPort fails outright, because its first assertion is that the documented encoding loads. That is the single failure red on the client linux-x64 leg and on all three OSS "Other tests (B)" legs, while macOS and Windows stay green. Worse, three tests asserting that a malformed entry does NOT load pass for the wrong reason - testCorruptFileReturnsNull, testEmptyFileReturnsNull, testTruncatedJsonReturnsNull. load() returns null for the directory, whatever the file holds. Proved by feeding testCorruptFileReturnsNull a perfectly valid document: before this change it failed at umask 022 and passed at 002; after it, it fails at both. Route all 24 sites through createStoreDir(), which creates the directory owner-only the way FileTokenStore itself does, and assert the permissions it got rather than assume them, so a silent revert to the umask cannot go unnoticed again. The one test that wants a loose directory still sets the permissions itself immediately after, which is an explicit statement rather than a property of whoever ran the build. FileTokenStoreTest: 59 tests green at umask 022, 002 and 077. Full suite at umask 002: 3341 tests, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/cutlass/auth/FileTokenStoreTest.java | 81 +++++++++++++------ 1 file changed, 57 insertions(+), 24 deletions(-) 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 index 46f095ef1..4245c0ae8 100644 --- 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 @@ -49,6 +49,7 @@ 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; @@ -88,6 +89,10 @@ * 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(); @@ -256,7 +261,7 @@ public void testClearOnEmptyStoreIsNoOp() throws Exception { public void testConcurrentStealContentionDegradesCleanly() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + createStoreDir(dir); TokenStoreKey key = sampleKey(); // a lock abandoned by a crashed holder, backdated well past the staleness window Path lock = lockFile(dir, key); @@ -348,7 +353,7 @@ public void testConcurrentStealersLeaveExactlyOneWinner() throws Exception { // separate io.questdb.client.test.* package with its own module-info, so package-private access // is structurally unavailable. Path dir = storeDir(); - Files.createDirectories(dir); + createStoreDir(dir); TokenStoreKey key = sampleKey(); Path lock = lockFile(dir, key); Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8)); @@ -403,7 +408,7 @@ public void testProcessLocksDoNotGrowWithTheIdentityCount() throws Exception { final int stripes = ((ReentrantLock[]) before).length; Path dir = storeDir(); - Files.createDirectories(dir); + createStoreDir(dir); FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000); AtomicInteger ran = new AtomicInteger(); // far more identities than stripes, so a map-backed implementation would visibly outgrow the table @@ -489,7 +494,7 @@ public void testReplaceTargetGivesUpAfterTheRetryBudget() throws Exception { // 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(); - Files.createDirectories(dir); + 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)); @@ -532,7 +537,7 @@ public void testReplaceTargetRetriesADeniedRenameThenSucceeds() throws Exception // 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(); - Files.createDirectories(dir); + 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)); @@ -577,7 +582,7 @@ public void testReplaceTargetRetriesADeniedRenameThenSucceeds() throws Exception public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + createStoreDir(dir); TokenStoreKey key = sampleKey(); // a lock abandoned by a crashed holder, backdated well past the staleness window Path lock = lockFile(dir, key); @@ -693,7 +698,7 @@ public void testCorruptFileReturnsNull() throws Exception { Path dir = storeDir(); FileTokenStore store = new FileTokenStore(dir); TokenStoreKey key = sampleKey(); - Files.createDirectories(dir); + createStoreDir(dir); Files.write(tokenFile(dir, key), "this is not json {{{".getBytes(StandardCharsets.UTF_8)); Assert.assertNull(store.load(key)); }); @@ -727,7 +732,7 @@ public void testEmptyFileReturnsNull() throws Exception { Path dir = storeDir(); FileTokenStore store = new FileTokenStore(dir); TokenStoreKey key = sampleKey(); - Files.createDirectories(dir); + createStoreDir(dir); Files.write(tokenFile(dir, key), new byte[0]); Assert.assertNull(store.load(key)); }); @@ -737,7 +742,7 @@ public void testEmptyFileReturnsNull() throws Exception { public void testEmptyLockStolenAfterGraceWithinStaleWindow() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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 @@ -767,7 +772,7 @@ public void testEmptyLockStolenAfterGraceWithinStaleWindow() throws Exception { public void testEmptyLockGraceIsNotShortenedByASmallStaleWindow() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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. @@ -798,7 +803,7 @@ public void testEnsureDirectoryTightensPreExistingDirPerms() throws Exception { 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 - Files.createDirectories(dir); + createStoreDir(dir); Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxrwxrwx")); FileTokenStore store = new FileTokenStore(dir); @@ -839,7 +844,7 @@ public void testFrozenSchemaEndpointsCarryAnExplicitPort() throws Exception { // 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(); - Files.createDirectories(dir); + createStoreDir(dir); FileTokenStore store = new FileTokenStore(dir); TokenStoreKey key = sampleKey(); String withPort = "{\"v\":1,\"client_id\":\"questdb\"," @@ -899,7 +904,7 @@ public void testInLockAbandonsFileLockWaitOnInterrupt() throws Exception { // 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(); - Files.createDirectories(dir); + createStoreDir(dir); FileTokenStore store = new FileTokenStore(dir, 30_000, 600_000); TokenStoreKey key = sampleKey(); Path lock = lockFile(dir, key); @@ -958,7 +963,7 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { // 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(); - Files.createDirectories(dir); + createStoreDir(dir); FileTokenStore holderStore = new FileTokenStore(dir, 30_000, 600_000); FileTokenStore waiterStore = new FileTokenStore(dir, 30_000, 600_000); TokenStoreKey key = sampleKey(); @@ -1031,7 +1036,7 @@ public void testInLockHonoursItsAcquireBudgetBehindALivePeerLock() throws Except // OidcDeviceAuthPersistenceTest.testGetTokenDegradesWhenStoreLockHeld; this pins the same bound // directly on the store, where such a call would live. Path dir = storeDir(); - Files.createDirectories(dir); + 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 @@ -1114,7 +1119,7 @@ public void testInLockDegradesWhenDirectoryUnusable() throws Exception { public void testInLockDegradesWhenHeldByFreshLock() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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(); @@ -1140,7 +1145,7 @@ public void testInLockDegradesWhenHeldByFreshLock() throws Exception { public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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 @@ -1204,7 +1209,7 @@ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception { public void testInLockReleaseDoesNotDeleteAStolenLock() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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(); @@ -1312,7 +1317,7 @@ public void testInLockRunsActionAndManagesLockFile() throws Exception { public void testInLockStealsStaleLock() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + createStoreDir(dir); // staleness threshold 100ms; the pre-created lock is backdated well past it FileTokenStore store = new FileTokenStore(dir, 2000, 100); TokenStoreKey key = sampleKey(); @@ -1571,7 +1576,7 @@ public void testOversizedFileReturnsNull() throws Exception { public void testOversizedStaleLockIsStolen() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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). @@ -1656,7 +1661,7 @@ public void testPermissionsOwnerOnly() throws Exception { public void testSaveFailureLeavesNoTempFileAndThrows() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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 @@ -1707,7 +1712,7 @@ public void testSchemaVersionMismatchReturnsNull() throws Exception { Path dir = storeDir(); FileTokenStore store = new FileTokenStore(dir); TokenStoreKey key = sampleKey(); - Files.createDirectories(dir); + 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\"," @@ -1749,7 +1754,7 @@ public void testSpecialCharactersAndNullsRoundTrip() throws Exception { public void testStaleTempFilesAreSweptOnSave() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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(); @@ -1828,7 +1833,7 @@ public void testTokenStoreKeyRejectsNullRequiredFields() throws Exception { public void testTruncatedJsonReturnsNull() throws Exception { assertMemoryLeak(() -> { Path dir = storeDir(); - Files.createDirectories(dir); + 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 @@ -1892,6 +1897,34 @@ private static void awaitInside(Thread t, String method) throws InterruptedExcep 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 From 9ceb0052cdec245cd89ef715e393fbfadc65b790 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 10:53:00 +0100 Subject: [PATCH 147/192] Give each identity its own in-process lock again 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, which a reuse-detecting provider answers by revoking the whole family. Fixing the earlier unbounded-map finding by striping 64 fixed locks kept that guarantee but bought it by serializing unrelated identities too, at a 1-in-64 collision rate per pair - and by pigeonhole, always, once a process mints more than 64. Over-serializing looked free and is not. This lock is held across a whole token-endpoint round trip, the acquire has no budget, and the caller holds its OidcDeviceAuth instance lock throughout. So a collision is not two identities "waiting for each other": one tenant's ILP flush blocks on another tenant's stalled refresh for that holder's entire worst case - four times httpTimeoutMillis plus an OS connect stall, two minutes on Linux against a black-holed provider - while every other caller on the blocked instance fails with "a token refresh is already in progress on another thread" and close() joins the queue behind it. One per end user in a multi-tenant service is the shape the field comment itself calls ordinary. It also quietly falsified OidcDeviceAuth.getToken()'s documented contract, which says the untimed in-process wait serializes "two instances sharing ONE IDENTITY in the same JVM". Keying the lock on the identity again makes that sentence true as written, so it needs no edit - the alternative was weakening the promise to match the code. Key on the identity fingerprint, count the callers holding or queued on each entry, and retire the entry when the last one leaves. That bounds the map by CONCURRENT identities - bounded in turn by live threads - rather than by identities ever seen, which is what the earlier finding actually objected to. compute() and computeIfPresent() apply their function atomically under the bin lock, so the counter needs no synchronization and retirement has no race: an arriving caller cannot observe an entry a departing one is removing. The old test asserted the table was the same array of the same length, which is true of any immutable array whether or not the code works. It now asserts the property: an entry exists while its critical section runs, and nothing is left behind once every caller has gone. The new test drives 65 identities - more than the 64 stripes, so pigeonhole guarantees a collision rather than merely making one likely - and requires all 65 to sit inside their critical sections at once. Counterfactual, with striping restored behind the same retain/release API so only the collision behaviour changes: Tests run: 3, Failures: 3 testUnrelatedIdentitiesDoNotSerializeOnEachOther tenant lock holder did not finish within 30s [state=RUNNABLE] testProcessLocksDoNotGrowWithTheIdentityCount the identity's lock must be held for the critical section The liveness half can only fail by timing out - a blocked thread has no other symptom - but it is bounded by joinOrFail at 30s and names what it was waiting for, rather than stalling the suite. Full suite at umask 002: 3342 tests, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 85 +++++++++----- .../test/cutlass/auth/FileTokenStoreTest.java | 111 ++++++++++++------ 2 files changed, 133 insertions(+), 63 deletions(-) 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 index c2ca3d300..4541f4a2d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -59,6 +59,7 @@ 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; @@ -171,18 +172,27 @@ public final class FileTokenStore implements TokenStore { // 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. // - // A fixed STRIPE TABLE, not a map keyed on the identity fingerprint. 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 the - // ConcurrentHashMap this replaced rooted a 64-char hash plus a lock for every one - // of them, for the life of the JVM, with nothing ever removing an entry. Striping is the right trade - // rather than reference-counted removal, because the lock only has to serialize AT LEAST every - // same-identity pair: two unrelated identities that land on one stripe merely wait for each other, which - // costs one of them the refresh round trip a same-identity peer would have cost anyway, while - // UNDER-serializing double-POSTs a rotating refresh token. Over-serializing is cheap and safe, - // under-serializing is neither - so a fixed table buys the bound for free, and with no removal race to - // get wrong. Keep the length a power of two: processLockFor() masks rather than divides. - private static final ReentrantLock[] PROCESS_LOCKS = newProcessLockTable(64); + // 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 @@ -347,20 +357,23 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { Thread.currentThread().interrupt(); return false; } - final ReentrantLock processLock = processLockFor(key); + // Retain before the acquire and release in the outermost finally, so every exit - the interrupted + // acquire below included - gives the claim back exactly once. + final ProcessLock processLock = retainProcessLock(key); // 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.lockInterruptibly(); + processLock.lock.lockInterruptibly(); } catch (InterruptedException e) { // Interrupted WAITING for the process lock: a live cancellation, acted on by abandoning the // refresh. Not re-asserted - the signal has been consumed by doing what it asked. The caller // learns through the false return (OidcDeviceAuth turns it into a credential failure), while // leaving the flag set would break every later blocking call on this thread, including the // teardown the interrupt was sent to enable. + releaseProcessLock(key); // the acquire never happened, so give the claim straight back return false; } try { @@ -438,7 +451,8 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { } } } finally { - processLock.unlock(); + processLock.lock.unlock(); + releaseProcessLock(key); } } @@ -599,13 +613,6 @@ private static String newLockNonce() { return System.currentTimeMillis() + " " + UUID.randomUUID(); } - private static ReentrantLock[] newProcessLockTable(int stripes) { - final ReentrantLock[] locks = new ReentrantLock[stripes]; - for (int i = 0; i < stripes; i++) { - locks[i] = new ReentrantLock(); - } - return locks; - } private static boolean nullableEquals(String keyValue, StringSink fileValue) { boolean fileHasValue = fileValue.length() > 0; @@ -749,13 +756,21 @@ private static void putStringMember(StringSink sink, String name, CharSequence v putString(sink, value); } - private static ReentrantLock processLockFor(TokenStoreKey key) { - // key.hash() is a hex SHA-256, so its bits are already uniform and String.hashCode inherits that; - // spread anyway - the standard HashMap mix - so a table this small never rides on the low bits alone. - // Masking with length-1 is why the length must stay a power of two, and it handles a negative - // hashCode (including Integer.MIN_VALUE) without the Math.abs trap. - final int h = key.hash().hashCode(); - return PROCESS_LOCKS[(h ^ (h >>> 16)) & (PROCESS_LOCKS.length - 1)]; + // 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(TokenStoreKey key) { + PROCESS_LOCKS.computeIfPresent(key.hash(), (identity, 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(TokenStoreKey key) { + return PROCESS_LOCKS.compute(key.hash(), (identity, existing) -> { + final ProcessLock held = existing != null ? existing : new ProcessLock(); + held.users++; + return held; + }); } private static byte[] readBounded(Path file) throws IOException { @@ -1249,6 +1264,18 @@ 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; 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 index 4245c0ae8..ad05c8869 100644 --- 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 @@ -54,13 +54,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -273,7 +271,7 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception { // 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 stripe for key.hash() before any lock-file logic, and that table is + // 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 @@ -396,54 +394,99 @@ public void testConcurrentStealersLeaveExactlyOneWinner() throws Exception { 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. The ConcurrentHashMap this replaced rooted a 64-char hash plus a lock for each of them, permanently: - // nothing removed an entry, and the comment claiming it was "bounded by identity count (a - // handful)" was a statement about the expected caller, not about the data structure. + // 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); - Object before = field.get(null); - Assert.assertTrue("the in-process locks must be a FIXED table, not a per-identity map: " + before, - before instanceof ReentrantLock[]); - final int stripes = ((ReentrantLock[]) before).length; + 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(); - // far more identities than stripes, so a map-backed implementation would visibly outgrow the table - for (int i = 0; i < 500; i++) { + 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", 500, ran.get()); + Assert.assertEquals("every identity must still have run its critical section", identities, ran.get()); - Object after = field.get(null); - Assert.assertSame("the stripe table must not be rebuilt", before, after); - Assert.assertEquals("the stripe table must not grow with the identity count", - stripes, ((ReentrantLock[]) after).length); - // and it must be usable, not merely present: a fresh identity still serializes + // 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 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); - // The index must actually spread. Nothing about correctness rests on it - landing every identity - // on one stripe still serializes same-identity pairs, which is all the lock owes anyone - but it - // would quietly serialize every unrelated identity in the process behind one another's refresh - // round trips, so a broken mask (an & 0, a constant) is worth catching here rather than in - // production latency. - Method processLockFor = FileTokenStore.class.getDeclaredMethod("processLockFor", TokenStoreKey.class); - processLockFor.setAccessible(true); - Set distinct = new HashSet<>(); - for (int i = 0; i < 500; i++) { - distinct.add((ReentrantLock) processLockFor.invoke(null, - new TokenStoreKey("client-" + i, "https://idp.example.com:443/token", - "https://idp.example.com:443/device", "openid", null, false))); + 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.assertTrue("500 identities must not all collide onto one stripe (got " + distinct.size() - + " of " + stripes + ")", distinct.size() > stripes / 2); + Assert.assertEquals("every identity must have entered its critical section", + identities, inside.get()); }); } @@ -590,7 +633,7 @@ public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exc 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 stripe, + // 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 From 42fd547dea51a747a079ef1a57ad7a96fa5e4cce Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 11:09:39 +0100 Subject: [PATCH 148/192] Refuse a served token that reads as a bare JSON null 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: an absent value must be OMITTED, which is the only encoding under which every present value round-trips verbatim. Nothing on the read side enforced it. A store file carrying "access_token": null reaches adopt() as the four characters "null" - non-blank, printable ASCII, and with a fingerprint that matches, since the fingerprint covers client_id, the endpoints, scope, audience and groups_in_token and never the token. It was adopted, and getToken() served it. That puts "Bearer null" on the wire, which the server answers with 401. Worse than a one-off rejection: the persisted expiry is still valid, so getToken() keeps serving it rather than refreshing, and the producer 401s with nothing naming the cause until the clamped expiry lapses. The trigger is a peer that violates the frozen format, which is exactly what the format was frozen to prevent - and json.dumps({"access_token": None}) is how a Python client sharing this store arrives there by accident. The wire-side parser already made the opposite call for the same reason: putNonNull folds "null" to absent so a JSON null in an identity-provider response cannot be cached as a token. Refuse it in adopt(), alongside the blank and control-character arms it already has, so a non-conforming writer degrades to an interactive sign-in. adopt() rather than FileTokenStore because it 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. The cost is refusing a real bearer token four characters long. The faithful round trip stays FileTokenStore's job and is unchanged - testLiteralNullStringTokenRoundTrip still passes, because the store still reads back exactly what it wrote; only the auth layer declines to serve it as a credential. Also corrects the comment that licensed this. It claimed a bare null was "harmless - a bogus token simply fails its fingerprint/char check", and neither check looks at the token value. Counterfactual, with only the "null" arm removed: OidcDeviceAuthPersistenceTest .testTamperedBareJsonNullServedTokenIsRefusedNotServedAsBearerNull expected:<[ACCESS-FRESH]> but was:<[null]> Full suite at umask 002: 3343 tests, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 15 +++++-- .../client/cutlass/auth/OidcDeviceAuth.java | 29 ++++++++++--- .../auth/OidcDeviceAuthPersistenceTest.java | 43 +++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) 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 index 4541f4a2d..4657276f1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -1405,10 +1405,19 @@ public void onEvent(int code, CharSequence tag, int position) { } 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 + // 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, which is harmless - a - // bogus token simply fails its fingerprint/char check and the entry falls back. + // 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/OidcDeviceAuth.java b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java index e847bc256..8a6d32e31 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1363,13 +1363,30 @@ private boolean adopt(PersistedToken token) { lastPersistedRefreshToken = fileRefreshToken; return true; } - if (Chars.isBlank(servedToken) || !hasOnlyTokenChars(servedToken)) { + 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), or - // carrying a control or non-ASCII character. 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. + // 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(); 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 index b6e88f5ea..2f23b8a09 100644 --- 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 @@ -1015,6 +1015,49 @@ public void testStoreLoadedAtMostOncePerInstance() throws Exception { }); } + @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(() -> { From ca7074a62d91a52db95d14c1b38b5267220473e7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 11:28:35 +0100 Subject: [PATCH 149/192] Decline a sign-in on a cancelled thread, store or not getToken() checks the calling thread's interrupt flag itself and refuses to spend a network round trip on a cancelled caller. signIn() did not, so the only guard it had was the one inside FileTokenStore.inLock - and that made its behaviour depend on whether a TokenStore was configured, wrong in opposite directions either way. With a FileTokenStore, inLock declined the carried interrupt by returning false. tryRefreshCoordinated() passed that up as "the refresh failed", so signIn() skipped a refresh it could have completed against a perfectly good token on disk and started the DEVICE FLOW instead: a browser prompt, and a poll loop that runs to the device-code lifetime because sleepBetweenPolls uses Os.sleep, which ignores interrupts. A caller that had cancelled got a prompt and a thread parked for up to half an hour - far more work than the round trip that was declined. With no store, tryRefreshCoordinated() went straight to tryRefresh() and POSTed to the token endpoint on that same cancelled thread. Check the flag in signIn(), after the cache read and before either path. A cached token needs no network and is still served whatever the caller's state; everything past that point is network work, which is what a cancellation is trying to stop. isInterrupted() rather than interrupted(), so the caller's signal survives the call, as getToken() and FileTokenStore.load()/save() already preserve it. The new test drives both configurations in one method, because the defect was the asymmetry and the two agreeing is the property worth pinning. Both halves reach signIn() holding a usable refresh token and an expired access token, so each has real work to decline. The store half uses a real FileTokenStore rather than the test double: inLock is what declines the interrupt, and FakeTokenStore runs its action, so a double would have exercised the no-store branch twice and never reached the prompt. Counterfactual, with only the guard removed: testSignInDeclinesOnAnInterruptCarryingThread a cancelled caller must not be signed in [withStore=true, served=ACCESS-1, deviceFlows=1, tokenCalls=1] deviceFlows=1 is the defect itself, and served=ACCESS-1 is the device grant rather than ACCESS-2 from a refresh - it prompted instead of using the token on disk. The assertion fires on the first half, so the no-store half is not reached in that run; driven separately it fails the same way with served=ACCESS-2, a refresh POST on a cancelled thread. Full suite at umask 002: 3344 tests, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 26 +++++++- .../auth/OidcDeviceAuthPersistenceTest.java | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) 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 index 8a6d32e31..c07d7c80a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -629,8 +629,10 @@ public String getToken() { * 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, or the identity provider - * does not return the expected 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(); @@ -652,6 +654,26 @@ public String signIn() { 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 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 index 2f23b8a09..119fb4d4d 100644 --- 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 @@ -467,6 +467,71 @@ public void testGetTokenBackOffExpiresWhileAProducerKeepsCalling() throws Except }); } + @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(() -> { From be0b821480a286f1bec85044d9b9aaa7759eb50d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 12:14:45 +0100 Subject: [PATCH 150/192] Restore what the review-pr skill sync overwrote Commit 9a5eb88e refreshed .claude/skills/review-pr/SKILL.md by copying the OSS master file over it verbatim. The refresh itself was worth having -- it brought 573 lines of upstream work, including the falsification and admission protocol, --range support, and the reworked level table -- but it also discarded the client-specific content the file had carried since f0b8b217, and it left one section addressed to the wrong repository. Restore the committed-binary gate. It never existed upstream, and this repo needs it: ci/build_native.yaml builds libquestdb from source on the runner precisely so the binaries stay out of the tree, so a build output in a diff is a supply-chain problem, not a style nit. The gate now reads --numstat in both PR and --range mode and sits in Step 1, which runs even when a level-0 review skips everything else. Cite the workflow paths that actually exist here (ci/build_native.yaml, .github/scripts/check-glibc-floor.sh) rather than the OSS-only rebuild_native_libs.yml, and carve out ci/cover-checker-*.jar, which CI drives from run_tests_pipeline.yaml and whose version bumps would otherwise trip the gate on every update. File the gate inside the new severity model instead of against it. The sync replaced blunt severities with an impact-first rubric that rules out Critical for team-only impact, so name the user consequence (unaudited code reaching the released artifact) and record the gate as static evidence, with the net determination and base-behavior check marked N/A -- static. Re-aim the store-and-forward checklist at this repo. Upstream kept the body but rewrote the triggers for the server side, pointing the reviewer at QWP ingress, submodule bumps, and questdb-ent/e2e suites, and added a subsection telling this repo what the server must do. Name the client's own entry points again (SenderPool, QueryClientPool, lazy_connect, initial_connect_retry), restore the three Verify imperatives upstream trimmed, and state the role-change contract as what the client may assume off the wire rather than as an obligation on a server that lives elsewhere. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/review-pr/SKILL.md | 91 +++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 998adc8a1..a734cf87c 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -95,6 +95,7 @@ Capture the PR identifier in `$PR` after stripping the level token, then fetch m PR='' gh pr view "$PR" --json number,title,body,labels,state gh pr diff "$PR" +gh pr diff "$PR" --numstat # binary files show as `--` gh pr view "$PR" --comments BASE=$(gh pr view "$PR" --json baseRefOid --jq .baseRefOid) ``` @@ -110,6 +111,7 @@ HEAD='' git diff "$BASE"${HEAD:+"...$HEAD"} --stat git diff "$BASE"${HEAD:+"...$HEAD"} git diff "$BASE"${HEAD:+"...$HEAD"} --name-only +git diff "$BASE"${HEAD:+"...$HEAD"} --numstat # binary files show as `--` ``` With `` empty the diff includes uncommitted working-tree changes, which is @@ -125,6 +127,17 @@ files, and findings are still classified by the same rubric. Restricting the review to the changed files would disable the out-of-diff breakage analysis that is the most valuable part of this skill. +### Committed-binary gate (mandatory at every level, both modes) + +Scan the `--numstat` output for any added or modified file git reports as +binary (`-`/`-` in the added and deleted columns). This repo builds its native +libraries from source in CI (`ci/build_native.yaml`, guarded by +`.github/scripts/check-glibc-floor.sh`) and does not commit build outputs, so +such a file is a **Critical** finding regardless of review level — report it +even at level 0, and even when every other step is skipped. See the "Committed +build artifacts" checklist for the rationale and the only acceptable +exceptions. + ## Step 2: PR title and description **Skipped in `--range` mode** — a local range has no PR metadata. State that it was skipped and continue at Step 2.4. @@ -281,7 +294,7 @@ The diff plus surface map can be large — write them to a shared file (e.g., un Use the following as a role catalog. Select only the roles allowed by the chosen level and change surface; do not launch the whole catalog. -**Agent 1 — Correctness & bugs:** NULL handling, edge cases, logic errors, off-by-one, operator precedence, error paths. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. When the diff touches the QWP ingress / role-gating path, an in-place switch or failover, a `questdb` submodule bump that carries client or ingress changes, or the `questdb-ent/e2e` failover/switch suites, also verify the "Store-and-forward & pool startup invariants" checklist — a change that lets a running SF drainer surface transport errors to the producer, imposes a reconnect time budget on it, or hard-fails it on a transient outage is a Critical (data-loss) finding. +**Agent 1 — Correctness & bugs:** NULL handling, edge cases, logic errors, off-by-one, operator precedence, error paths. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. When the diff touches the store-and-forward sender, the async drainer / send loop, primary reconnect/failover, pool startup (`lazy_connect` / `initial_connect_retry` / `SenderPool` / `QueryClientPool`), or the QWP upgrade / role-gating path, also verify the "Store-and-forward & pool startup invariants" checklist — a running drainer that propagates a transport error to the caller, imposes a reconnect time budget, or hard-fails on a transient outage is a Critical (data-loss) finding. **Agent 2 — Concurrency:** Race conditions, shared mutable state, missing volatile, lock ordering, thread-safety of data structures. Use the implicit contract list (lock order, thread-affinity) and check every callsite from 2.5b for violations of the new contract. @@ -325,7 +338,7 @@ that amplifies an otherwise-acceptable cost. **Agent 5 — Test review & coverage:** Coverage gaps, error path tests, NULL tests, boundary conditions, regression tests, test quality, `assertMemoryLeak()` usage. Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. For each missing cross-context test, add an `UNTESTED` Step 2.6 row; do not predetermine its severity or publication. Consume the Step 2.6 coverage map: re-verify every claimed test and failure link (read the assertion, don't trust the map), and hunt for behavioral changes the map missed. Then run a **mutation spot-check**: pick the 3-5 most dangerous changed lines (boundary comparisons, error handling, null checks, off-by-one candidates) and ask, per line, "which test fails if this line is wrong — inverted condition, off-by-one, dropped null check?" When no assertion would catch a mutation, add an `UNTESTED` map row even if a test nominally executes the line; classify it under Step 2.6 and publish it only after Step 3b admission. **Enforce the "SQL test assertions (builder API — strict)" checklist on every added/modified test line: any new `assertSql(...)`/`assertPlanNoLeakCheck(...)`/`getPlan(...)`/`TestUtils.assertSql(...)` is Critical; any new `.returnsOnce(...)` on a deterministic (non-RNG, non-time-varying) query is Critical; a lone `assertQuery(...)` wrapped in `assertMemoryLeak(...)` is a finding.** Test *efficacy* (whether tests actually exercise the change and could fail) and test-*code* quality are handled by Agents 12-14 — here, focus only on whether coverage exists for every new or changed path. -**Agent 6 — Code quality & standards:** Code smell, member ordering, naming conventions, modern Java features, dead code, third-party dependencies. **Also check for unclosed LOG statements**: QuestDB logging uses a builder pattern (`LOG.info().$("msg").$()`) and every chain MUST end with `.$()` or `.I$()`. A missing close holds a ring buffer slot forever, causing other log producer threads to busy-wait in `nextBully()`, and the log consumer `logging_0` thread cannot progress either. Also watch for `.put()` instead of `.$()` in LOG chains — `.put()` returns `Utf16Sink`, not `LogRecord`, breaking the chain. Also flag throw-capable expressions inside LOG chains (`LOG.info().$(func()).$()`): arguments are evaluated after the ring slot is acquired, so a throwing `func()` unwinds past the terminator and leaks the slot; the call must be hoisted into a local before the chain starts. +**Agent 6 — Code quality & standards:** Code smell, member ordering, naming conventions, modern Java features, dead code, third-party dependencies. **Also check for unclosed LOG statements**: QuestDB logging uses a builder pattern (`LOG.info().$("msg").$()`) and every chain MUST end with `.$()` or `.I$()`. A missing close holds a ring buffer slot forever, causing other log producer threads to busy-wait in `nextBully()`, and the log consumer `logging_0` thread cannot progress either. Also watch for `.put()` instead of `.$()` in LOG chains — `.put()` returns `Utf16Sink`, not `LogRecord`, breaking the chain. Also flag throw-capable expressions inside LOG chains (`LOG.info().$(func()).$()`): arguments are evaluated after the ring slot is acquired, so a throwing `func()` unwinds past the terminator and leaks the slot; the call must be hoisted into a local before the chain starts. Also scan the diff for any committed compiled binary / build artifact (read the `--numstat` output captured in Step 1 and flag every file git reports as binary) — the native libraries are built from source in CI, so a committed build output is a **Critical** finding (see the "Committed build artifacts" checklist). **Agent 7 — PR metadata & conventions:** Title format, description quality, commit messages, labels, SQL style in tests. @@ -577,6 +590,30 @@ of the two categories it lands in, per the magnitude rule in Step 4. - Code smell: overly complex methods, deep nesting, unclear intent, dead code - No third-party Java dependencies on data paths +### Committed build artifacts +- **A newly committed compiled binary is always Critical.** This repo builds its + native libraries from source in CI (`ci/build_native.yaml`, guarded by + `.github/scripts/check-glibc-floor.sh`) and does not commit build outputs. A + binary added or modified in the diff cannot be reviewed, audited, or + reproduced from source, can smuggle in unaudited or malicious code, and bloats + the repo history irreversibly — so it blocks the merge. +- Detect it structurally, not by extension alone: read the `--numstat` output + from Step 1 (or run `git diff --stat`) and flag every added/modified file git + reports as binary (`numstat` shows `-`/`-` for added/deleted lines; `--stat` + shows a `Bin ... -> ... bytes` marker). Typical offenders: `.so`, `.dylib`, + `.dll`, `.a`, `.o`, `.lib`, `.exe`, `.class`, `.jar`, `.war`, `.wasm`, + `.node`, `.bin`. +- The finding stands even when the binary "looks" legitimate (e.g. a rebuilt + `libquestdb.*`): the correct source of these artifacts is the CI native-build + pipeline plus release packaging, never a PR diff. +- **The only acceptable exceptions** are files that are inputs rather than + outputs: genuine test fixtures/resources (data a test reads), and the + checked-in CI tool jar (`ci/cover-checker-*.jar`) whose version bumps are a + pre-existing, deliberate decision. Both still need justifying in the PR; a + new binary outside these two classes never does. +- Suggested fix: drop the binary from the PR, confirm a `.gitignore` entry + covers it, and let CI native-build + release packaging produce it. + ### QuestDB coding standards - Class members grouped by kind (static vs instance) and visibility - Boolean names use `is...` / `has...` prefix @@ -607,16 +644,13 @@ For each new or changed allocation site, verify: - **Nested SQL inherits the outer tracker.** Subqueries, the mat-view refresh inner SELECT, and WAL apply inner SQL must inherit the tracker already bound on the context, not acquire their own. A new acquisition site that acquires unconditionally (instead of only when no outer tracker is present) double-counts — flag it. - **Coverage has a test.** A newly wired allocator needs a `*MemoryTrackerTest` proving (a) a breach throws the per-query out-of-memory message, (b) an under-limit run succeeds, and (c) a `getCursor()`-to-close leak loop stays balanced. Record a missing tracker test or an unpinned factory-class routing guard as an `UNTESTED` Step 2.6 row; classify and publish it only through the normal proportionality and admission gates. -### Store-and-forward & pool startup invariants (QWP client contract) -Apply this whenever the diff touches the QWP ingress path (upgrade/role -gating, in-place demote / lifecycle switch, connection handling on role -change), replication failover, a `questdb` submodule bump that carries -client (`java-questdb-client`) or ingress changes, or tests that drive a -producer through a failover/switch window (e.g. the `questdb-ent/e2e` -failover/switch suites). These are the CLIENT's store-and-forward -guarantees (the client code lives in the nested `questdb/java-questdb-client` -submodule); server-side changes and tests in this repo must be reviewed -against them. A violation here is a **Critical** finding: the whole point of +### Store-and-forward & pool startup invariants (QWP facade) +Apply this whenever the diff touches the SF sender, the async drainer / send +loop, primary reconnect/failover, `SenderPool` / `QueryClientPool` startup, +`lazy_connect` or `initial_connect_retry`, the QWP upgrade / role-gating path +and connection handling on a role change, or any test that drives a producer +through a failover/switch window. These are this client's own store-and-forward +guarantees. A violation here is a **Critical** finding: the whole point of store-and-forward is that a running producer never loses data and never hard-fails on a transient outage. @@ -673,27 +707,33 @@ hard-fails on a transient outage. - `lazy_connect=true`: `build()` MUST succeed with **no server present**. The producing `Sender` must work immediately (writes buffer via SF), and once the server comes up the read side must also connect and read (reads are deferred, - not disabled). + not disabled). Verify `build()` does not fail-fast, the sender does not throw + on the first write while the server is down, and a later `borrowQuery()` + succeeds once the server is up. - `lazy_connect=false` (default): `build()` / the initial connect MUST expose connectivity problems to the caller — DNS errors, connect-refused / unreachable, TLS/cert, authentication/authorization, and connect/upgrade timeouts must all surface as a thrown exception at startup, not be swallowed. + Verify each of those failure classes reaches the user during initialization. - **In BOTH modes the boundary is the same:** connectivity errors are only ever the caller's problem DURING initialization. Once the client has connected and is past initialization, the running drainer reverts to the steady-state contract above — it must NEVER expose transport problems, NEVER impose a reconnect time budget, and NEVER hard-fail on a transient outage. - -**Server-side & test application (this repo).** -- The server MUST NOT rely on producer-visible role errors: an in-place - demote CLOSES QWP ingress connections (no per-write SECURITY_ERROR to an SF - sender). A server change that reintroduces per-write role errors on the QWP - ingress path breaks the containment contract above. -- Flag any test (unit, integration, or e2e) that uses QWP producer-visible - role errors as evidence of the REPLICA write gate — under the containment - contract the producer is silent by design. Write-gate evidence belongs on - pg-wire probes, frozen commit counts on the settled replica, and - post-promotion SF drain (durable-ack await barriers + dense oracles). + Anything that undermines the store-and-forward guarantee past init is + Critical. + +**Role changes on the wire (what the client may assume).** +- The server does NOT surface a role change as a per-write error: an in-place + demote CLOSES the QWP ingress connection rather than answering an SF sender + with a per-write SECURITY_ERROR. Client code that treats a demote as a + per-write security failure — latching terminal, dropping the batch, or + propagating to the producer — is reading a signal the server does not send; + the correct handling is the transport-close path (reconnect, keep buffering). + Flag any client change that reintroduces that assumption. +- Flag any test (unit or integration) that uses producer-visible role errors as + evidence of a write gate — under the containment contract the producer is + silent by design, so such a test asserts a signal that will not arrive. - Dense/count oracles over rows produced through an SF sender must account for at-least-once replay: durably ack (await) seed rows before the disturbance, or use a DEDUP table — otherwise the oracle reports replay @@ -800,6 +840,7 @@ Severity is a function of **what the user loses**, not of which checklist the fi - **wrong or missing data** — incorrect query results, silent truncation, lost or duplicated rows, corrupted on-disk state, divergent replica, wrong materialized-view content; - **a crash, hang, or unavailability** — panic, deadlock, livelock, unbounded loop, OOM, fd/thread/connection exhaustion, or a leak that grows without bound under a repeatable operation; - **a security or ACL failure** — privilege bypass, permission not enforced, credential or cross-tenant data exposure; +- **unauditable code shipped to users** — a compiled binary or other build output committed in the diff: it cannot be reviewed, reproduced from source, or audited, so unvetted (or malicious) code reaches the released artifact; - **a broken or misleading failure mode** — an operation that fails with no error or the wrong error, an error message the user cannot act on, an exception swallowed so failure looks like success, a fault lost or unlogged such that an incident cannot be diagnosed; - **a compatibility break** — on-disk format, wire protocol, public/SQL/JNI API, or config semantics changed so existing clients, existing data, or a rolling upgrade break; - **a performance or IO regression the user can feel** — per the magnitude rule below; @@ -844,6 +885,8 @@ Blocking issues introduced or exposed by this PR, ordered worst user impact firs - For performance findings: the magnitude statement (the multiplier and what it multiplies) - Suggested fix, written to be applied in THIS PR +**A newly committed compiled binary or other build artifact is always Critical**, no matter how legitimate it looks — native libraries are built from source in CI, so a build output in the diff is never acceptable (see the "Committed build artifacts" checklist). This one is a supply-chain gate rather than a behavioral defect: the `--numstat` evidence from Step 1 is the whole proof, so the net determination and the base-behavior check are recorded as `N/A — static`. + Pre-existing/not-attributed observations are never Critical; a fully proved one belongs under Adjacent findings instead. ### Moderate From 72c58287e57bcd5c656d578b9192311036475b8c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 13:38:53 +0100 Subject: [PATCH 151/192] Say elapsed time where the comments claimed the wall clock Six comments described the bounded-read deadline as a "wall-clock" bound. It has always been System.nanoTime(), and 67542a2a made that distinction load-bearing in this file: an elapsed budget must not be stretched by an NTP step or an operator setting the date back, so "wall clock" now names a specific defect rather than reading as a loose synonym for "deadline". A reader taking the words at face value would go looking for a bug that is not there - or, worse, copy the wrong idiom into the next bound. - OidcDeviceAuth.parseBody, where the deadline is defined, and the two comments that refer back to it (the discovery gate and the disconnect-on-abort handler in postForm) - AbstractResponse.recv and AbstractChunkedResponse.recv, which name parseBody as the caller whose bound they must not defeat - AbstractLineHttpSender's flush-path note, which contrasts its per-read bound with parseBody's cumulative one The three wall-clock comments nearby are correct and left alone: nextStoreLoadAttemptMillis and PersistedToken's expiry are absolute instants read from currentTimeMillis, and acquireForGetToken's is the rationale 67542a2a wrote for choosing nanoTime over it. parseBody now carries that same rationale, since it is the definition site. README also documents the two back-offs that were behaviour with no prose. Both are user-visible on the getToken() hot path and neither was findable outside the source: - a failed silent refresh is not retried for 5 s, so a producer retrying rows cannot drive a token-endpoint round trip per flush; signIn()/clearCache() clear it - a TokenStore.load that THROWS is retried once immediately, then backs off 5 s doubling to a 60 s cap; a store with nothing to return reports that by returning null and is unaffected Comments and docs only - no behaviour change. mvn compile clean. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++++ .../io/questdb/client/cutlass/auth/OidcDeviceAuth.java | 10 ++++++---- .../cutlass/http/client/AbstractChunkedResponse.java | 2 +- .../client/cutlass/http/client/AbstractResponse.java | 2 +- .../cutlass/line/http/AbstractLineHttpSender.java | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9bbeff33f..d3d77aa4f 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,8 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.c For a standalone sender, use `httpTokenProvider(auth::getToken)` for the same rotating-token behavior. A fixed `httpToken(token)` or `token=` connect-string value captures the token once, so a client that reconnects after that token expires starts failing authentication. Hand rotating credentials to the provider API, not a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. +`getToken()` sits on a hot path — it is called once per ILP flush and once per WebSocket upgrade or reconnect — so a credential failure is rate-limited rather than retried on every call. When a silent refresh fails, `getToken()` does not attempt another one for 5 seconds: calls inside that window fail immediately, asking for an interactive `signIn()`, instead of hitting the identity provider again. Without that guard a producer retrying its rows would drive one token-endpoint round trip per flush, blocking the producer thread for each one and hitting the provider hard enough to trip its rate limits and lengthen the very outage being retried. Only a real refresh attempt arms the guard, and an explicit `signIn()` or `clearCache()` clears it outright. It is deliberately short — a stampede guard, not a circuit breaker — so a credential that comes back within seconds is picked up on the first call after the window rather than on the first call after it recovers. + By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in (the client declares `requires static java.desktop`, so the module is optional at run time and its absence can never break module resolution; a modular application therefore gets the browser launch only when `java.desktop` is in its own module graph) — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: ```java @@ -477,6 +479,8 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( `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: an entry read from a directory other local users can write is discarded and the directory tightened back to `0700`. 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). 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 index c07d7c80a..0286eff31 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -855,7 +855,7 @@ private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfigura // 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 a wall-clock deadline and a byte cap so an untrusted server cannot wedge + // 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) { @@ -1021,8 +1021,10 @@ private static String originOf(Endpoint endpoint) { } private static void parseBody(Response body, JsonLexer lexer, JsonParser parser, int timeoutMillis) throws JsonException { - // read and parse the whole body, bounded by a wall-clock deadline and a cumulative byte cap, so a - // hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming + // 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) { @@ -1686,7 +1688,7 @@ private void postForm(Endpoint endpoint, JsonParser parser) { response.await(httpTimeoutMillis); readResponse(client, response, parser); } catch (HttpClientException e) { - // a transport failure, or a bounded-read abort in parseBody (its wall-clock deadline or the + // 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 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 7d7954229..545dbf461 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 @@ -94,7 +94,7 @@ 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 wall-clock bound (e.g. OidcDeviceAuth.parseBody). A non-positive + // 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; 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 b234d2a24..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 @@ -69,7 +69,7 @@ public Fragment recv(int timeout) { // 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 wall-clock bound (e.g. OidcDeviceAuth.parseBody). A non-positive + // 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; 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 8099785d1..9e41b9148 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 @@ -739,7 +739,7 @@ private void flush0(boolean closing) { // 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 wall-clock time against an untrusted identity provider). + // 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 From dc6b1a6add480785432891aac1f3aa1858324eeb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 14:31:49 +0100 Subject: [PATCH 152/192] Assert the leak test actually injected a failure assertConstructionFailureLeaksNothing put its Assert.fail() inside the try, under catch (Throwable expected). fail() throws AssertionError, which is a Throwable, so the catch swallowed the very assertion meant to catch a no-op run. All four tests share this helper, so if any injection ever stopped failing - a facade signature drifting, Unsafe.malloc tolerating a negative size, a poller gaining its own guard - the test would construct a client successfully, close it in the finally, and report a pass having injected nothing. Same shape as row 47 of the previous round. The flag rather than a narrower catch, because the four injections share no supertype below Throwable: Epoll and Kqueue throw NetworkError, which extends Error, while the base buffer and FDSet paths throw IllegalArgumentException out of Unsafe.malloc. Catching either alone would let the other platform's failure escape as an error rather than be recognised as the expected one. Proven by neutering both injections that run on this machine - kqueue() returning the real descriptor, getResponseBufferSize() returning the default: before: Tests run: 4, Failures: 0, Skipped: 2 -- BUILD SUCCESS after: Tests run: 4, Failures: 2, Skipped: 2 -- BUILD FAILURE "construction succeeded, so this test's injected failure no longer fires and it proved nothing" Restored, the class is green again (4 run, 2 skipped: epoll and FDSet belong to CI's platforms). Test-only change. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/client/HttpClientConstructorLeakTest.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 index 41bb47075..7c17f0543 100644 --- 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 @@ -158,17 +158,29 @@ public int getWaitQueueCapacity() { 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); - Assert.fail("expected the poller's initialisation failure to abort construction"); } 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 + ); } } From be6ce1ce839e336c6a3e6ab3680775730f12dcd6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 14:43:37 +0100 Subject: [PATCH 153/192] Pin the rotating-401 dwell clamp at its call site The clamp had one test, and it asserted the pure function: dynamicCredentialAuthDwellNanos(Long.MAX_VALUE) returns the ceiling. That says nothing about the connect loop, which is the only place the value is consulted. Every end-to-end drainer test configures a dwell far below the ceiling - 25ms, 100ms, 250ms - where Math.min returns its first argument either way, so the loop reverting to a raw TimeUnit.MILLISECONDS.toNanos(reconnectMaxDurationMillis) left the whole suite green. What that revert costs is the defect the clamp exists for. 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, and the rotating-401 gate is an AND, so the dwell conjunct becomes unsatisfiable: the ride-out never ends, no .failed sentinel is written, no DATA_LOSS is reported, and the slot lock plus one worker of a FIXED-size drainer pool stay pinned for the life of the process, starving every other orphan slot. testConnectLoopAppliesTheClampedRotating401Dwell drives the real loop against reconnect_max_duration_millis=Long.MAX_VALUE. Reaching the ceiling honestly costs five minutes of wall clock per run, so the rejection anchor is pre-aged past it instead, through a new @TestOnly seam beside the two already on this class - the loop only stamps the anchor when it is still 0, so a pre-aged one survives into the comparison the test is about. The scripted factory stops the drainer after 30 sweeps so the unclamped case fails on a named assertion rather than running to the test timeout. Counterfactual, with the call site reverted to the raw budget: BackgroundDrainerDurableAckRetryTest: Tests run: 31, Failures: 1 testConnectLoopAppliesTheClampedRotating401Dwell a saturated reconnect_max_duration_millis must not disable the escalation - the connect loop has to use the CLAMPED dwell [attempts=30] expected: but was: The other 30 - including the pure-function clamp test - stayed green under that revert, which is the gap itself. Restored: 31/31, and 329/329 across the drainer, cursor send-loop, orphan-scan and SF pool suites. Also corrects the ride-out WARN, which reported the raw reconnect_max_duration_millis as the dwell. That is 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. It now reports the dwell the gate actually applied. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 27 +++++++- .../BackgroundDrainerDurableAckRetryTest.java | 62 +++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) 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 924feb1a3..fff4c5b36 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 @@ -491,13 +491,17 @@ public WebSocketClient connectWithDurableAckRetry() { 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, - reconnectMaxDurationMillis, e.getMessage()); + dynamicCredentialAuthDwellNanos / 1_000_000L, e.getMessage()); // fall through to the shared capped-backoff block } else { String msg = e.getMessage(); @@ -752,6 +756,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 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 42f492aee..14abc248d 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 @@ -389,10 +389,12 @@ public void testRotatingCredentialAuthDwellIsClampedSoEscalationStaysReachable() // 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. // - // Asserted on the clamp directly rather than end to end: proving it through connectWithDurableAckRetry - // means waiting out the ceiling, five minutes of wall clock. The other half of the argument - that a - // FINITE dwell does quarantine - is what testRotatingCredentialAuthRejectionQuarantinesOnceBudgetExhausted - // drives, with a 25ms budget. Finite dwell quarantines, and the dwell is always finite. + // 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); @@ -412,6 +414,58 @@ public void testRotatingCredentialAuthDwellIsClampedSoEscalationStaysReachable() 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 testTransientOutageDoesNotCountTowardTheRotating401Dwell() throws Exception { assertMemoryLeak(() -> { From d23e95294e0fb45977017cd2acd52a37f85a2b1f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 14:52:14 +0100 Subject: [PATCH 154/192] Discard the whole store directory, not one entry, when it was exposed restrictToOwner computes the trust verdict from the directory's current permissions and then chmods it to 0700, so the act of reporting the verdict destroys it. Only load() consumed the boolean at all, and only for its own .json; save() and inLock() called ensureDirectory and threw the result away. One store directory holds one file per configuration, so whichever caller touched the store first spent the single observation on behalf of everybody: - identity A's load correctly refused its entry and tightened the directory. Identity B's load then read 0700, judged the directory trusted, and adopted a .json planted during the same window. - a save arriving before any load did the same with no refusal at all: it tightened the directory and left every planted entry in it looking to every later load like it had always been protected. Both are now covered by tests that fail without the fix: testWorldWritableVerdictIsNotConsumedByWhicheverIdentityLoadsFirst 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 expected null, but was: testWorldWritableVerdictSurvivesASaveTouchingTheDirectoryFirst an entry exposed before that save must not be adopted afterwards expected null, but was: The fix is directory-wide discard rather than an in-memory latch: a latch would have to be scoped per directory to be right, and would still not survive the process, while deleting the content is correct across instances and across processes. All three entry points now act on the verdict. Write temps go with the entries, since one holds a full serialized entry; .lock files and in-flight steal captures (.lock..tmp) stay, carrying no token, and acquireLock already treats a hostile or stale lock as stealable. Scope, deliberately narrow. Only GROUP_WRITE or OTHERS_WRITE trips this - the 0755 a default umask produces still loads normally, since no other user can create or replace an entry there and the files are 0600. That distinction is what testLoadTrustsAWorldREADABLEDirectory pins, and without it every negative assertion in this suite would pass for the wrong reason. This is defence in depth beyond the documented promise: README already states the store is not cryptographically authenticated and that anyone who can write the file can substitute a well-formed entry. It is worth closing anyway because the container check is the only thing that stands between a complete plant and adoption - the per-file defences (size bound, fingerprint re-check, validateTokenChars) all pass on a well-formed one. design/oidc-token-persistence.md gains the container check as a stated REQUIREMENT, directory-wide version included: it is the frozen cross-language contract, it did not describe the check at all, and a client porting the obvious per-entry form inherits exactly this bug. README's one-line description is corrected to match. Auth suite: 231 tests, 0 failures (FileTokenStoreTest 62, OidcDeviceAuthTest 125, OidcDeviceAuthPersistenceTest 44). Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../client/cutlass/auth/FileTokenStore.java | 74 ++++++++++++++--- .../test/cutlass/auth/FileTokenStoreTest.java | 79 +++++++++++++++++++ design/oidc-token-persistence.md | 10 +++ 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d3d77aa4f..e900a3adf 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( 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: an entry read from a directory other local users can write is discarded and the directory tightened back to `0700`. Inside those permissions, though, the client cannot tell a planted credential from its own. +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). 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 index 4657276f1..436663c6c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -393,7 +393,11 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // set when an interrupt arrives while we poll for the cross-process lock; see acquireLock boolean cancelled = false; try { - ensureDirectory(); + 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) { @@ -485,17 +489,12 @@ public PersistedToken load(TokenStoreKey key) { 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 the entry - // rather than merely skip it: leaving it would hand the very same file to the next load, - // which now sees an owner-only directory and would trust it. - warnUnprotectedStoreDirOnce("it was writable by other local users; the entry found in it was " - + "discarded rather than trusted, and a fresh sign-in is required"); - try { - Files.deleteIfExists(tokenFile(key)); - } catch (IOException ignore) { - // best-effort: a delete failure must not turn an untrusted entry into a thrown load - } - sweepTempFiles(key.hash(), 0L); + // 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); @@ -526,7 +525,14 @@ public void save(TokenStoreKey key, PersistedToken token) { try { byte[] content = serialize(key, token); try { - ensureDirectory(); + 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()); @@ -1073,6 +1079,48 @@ private Path createTempFile(String prefix) throws IOException { } } + /** + * 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(); + final boolean isEntry = 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. * 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 index ad05c8869..265019c5d 100644 --- 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 @@ -1472,6 +1472,85 @@ public void testLoadRejectsAndDiscardsAnEntryFromAWorldWritableDirectory() throw }); } + @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 testLoadTrustsAWorldREADABLEDirectory() throws Exception { Assume.assumeTrue("POSIX permissions are needed to loosen the store directory", diff --git a/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index c52b6a899..342d56c5c 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -302,6 +302,16 @@ whole reason persistence is **opt-in**. Mitigations, mapped to PR #52's existing — exactly the CR/LF / non-ASCII rejection PR #52 applies to IdP responses (`OidcDeviceAuth.java:935-951`). A bad file degrades to an interactive sign-in; it never injects into a request or throws token bytes into a message. +- **Untrusted CONTAINER = discard the whole directory.** The file checks above cover the + artefact; they say nothing about who could have put it there. On POSIX, assert the store + directory is not group/other-**writable** before adopting anything out of it (a merely + world-*readable* 0755 from a default umask is fine — no other user can create or replace an + entry, and the files are 0600). When it *was* writable, tighten it to 0700 and discard + **every** entry in it, not only the key being loaded. A client MUST do the directory-wide + version: tightening destroys the very evidence it reports, so whichever identity — or + whichever operation, load *or* save — touches the store first consumes the one observation, + and every entry left behind is one no later call can distrust. Each identity then re-signs + in. Best-effort: a delete that fails must degrade to a sign-in, never throw. - **Never log/echo secrets.** The store never logs token contents and never embeds file contents in an exception, upholding PR #52's "tokens never leak into logs or exceptions" rule. Only paths and `IOException` kinds appear in the one best-effort warning. From 3052914376163a262c99afb01380d91e27068081 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 15:28:57 +0100 Subject: [PATCH 155/192] Pin the exported signatures this branch had to put back Three public methods were replaced rather than added to, all in packages module-info.java exports and that ship a javadoc jar: Response.recv(int) arrived as an abstract interface method, two QwpWebSocketSender.connect( ..., String, ...) overloads were retyped to Supplier, and the multi-host AbstractLineHttpSender.createLineSender gained a parameter in place. A caller compiled against an earlier release breaks with NoSuchMethodError, an external Response implementation with AbstractMethodError. All three were restored in the second review round. Nothing asserted it, in this repo or either parent, and this build has no japicmp or revapi gate - so the restoration was one careless edit away from being undone with a green build, exactly as the original break was latent. ExportedApiCompatibilityTest is that gate. The eleven connect and two createLineSender signatures present at the merge base (2489b243) are written out literally as name(paramType,...)returnType over erased type names, rather than derived from the current classes: a pin computed from the thing it pins proves nothing. Adding an overload keeps it green; retyping or removing one turns it red. LegacyResponse implements Response and overrides recv() only - the shape of an implementation written before the overload existed - so it does not compile if recv(int) goes back to abstract, and it asserts the default still IGNORES the bound and defers to recv(), which is the previous behaviour rather than merely a link. Each of the three break shapes reproduced against the test: recv(int) made abstract again: ExportedApiCompatibilityTest.java:[174,25] error: LegacyResponse is not abstract and does not override abstract method recv(int) in Response -- BUILD FAILURE, at compile time, before any test runs connect(...)'s trailing int retyped in place to Integer (chosen because it still compiles internally, via boxing, the way the real String -> Supplier retype did): Tests run: 4, Failures: 1 these QwpWebSocketSender.connect signatures existed at the merge base and no longer do ... Add an overload instead of retyping one. the provider-less multi-host createLineSender removed, which is what "gained a parameter in place" leaves behind: Tests run: 4, Failures: 1 these AbstractLineHttpSender.createLineSender signatures existed at the merge base and no longer do ... Restored: 4/4, and 269/269 across the response, ILP sender, QWP query client and token-provider suites. This is a targeted pin, not a general compatibility gate - it covers the three methods this branch touched. A japicmp or revapi baseline check would cover the whole exported surface and is the durable answer; it needs a baseline artifact and a CI decision, so it is left as a follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- .../compat/ExportedApiCompatibilityTest.java | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/compat/ExportedApiCompatibilityTest.java 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; + } + } +} From 6204a0e7de536ff4767a159ab1492f7db31ee156 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 15:41:19 +0100 Subject: [PATCH 156/192] Snapshot a pulled token before validating it HttpTokenProvider.getToken() returns a CharSequence, and the contract covers only that the provider be thread-safe - while the sender's own comment endorses reusing one buffer as "the idiomatic zero-alloc style". Every reader then validated the provider's live sequence and re-read it to build the header: AbstractLineHttpSender.stampTokenIfPending validate, then authToken() Sender.LineSenderBuilder (WebSocket auth) validate, then "Bearer " + t QwpQueryClient.resolveAuthorizationHeader validate, then "Bearer " + t Two reads of a buffer the caller does not own. A mutation landing between them passes the check and is spliced into the header verbatim - CR/LF included, which is the exact injection validateToken exists to stop. The sibling test testMutatedSameInstanceProviderTokenIsRevalidated covers a buffer mutated BETWEEN flushes, which re-validation catches; this is the window inside ONE flush, which it cannot. Not reachable in-tree: OidcDeviceAuth::getToken returns a String. But HttpTokenProvider is an exported SPI in a package module-info.java exports, and its javadoc invites exactly the buffer reuse that opens the window, so the reader has to be what closes it. All three sites now snapshot with toString() before validating, so the bytes checked are the bytes sent. Null-safe, so a null pull still reaches validateToken's "null or empty" message rather than an NPE. The two "Bearer " + token sites paid for the String already - the concatenation called toString() a line later - so this only moves when it happens. The ILP path adds one String per FLUSH, not per row, next to a network round-trip and the O(n) scan already sitting there. The contract says both halves now: getToken()'s javadoc states that a reused mutable buffer is supported and expected but must not be mutated while the client is reading it, and validateToken's states that callers must pass a value that cannot change between the check and the write, and why. testTokenMutatedBetweenValidationAndTheHeaderCannotSplice drives it with HandOffToken, a buffer that swaps its content the instant a full scan completes - which is exactly when validateToken finishes, the narrowest version of the window. Counterfactual, with only the ILP snapshot reverted: org.junit.ComparisonFailure: the header must carry the bytes that were validated, not a value swapped in after the scan expected: but was: Truncated at the CR/LF, so the splice reached the wire past the auth line. Restored: 13/13 in that class, 305/305 across the token-provider, ILP sender, QWP query client and exported-API suites. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 20 ++++- .../main/java/io/questdb/client/Sender.java | 6 +- .../line/http/AbstractLineHttpSender.java | 12 ++- .../cutlass/qwp/client/QwpQueryClient.java | 7 +- .../line/LineHttpSenderTokenProviderTest.java | 76 +++++++++++++++++++ 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index 31e3db1a9..62e21de76 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -56,7 +56,16 @@ public interface HttpTokenProvider { /** * Validates a token returned by {@link #getToken()} before the client writes it into an - * {@code Authorization: Bearer} header. Rejects a null, empty or blank token, and any token + * {@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 @@ -84,6 +93,15 @@ static void validateToken(CharSequence token) { * 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 */ diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 4b980eb86..8e83ec3c7 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3438,7 +3438,11 @@ private Supplier buildWebSocketAuthHeader() { // than send a malformed or CR/LF-injected "Bearer " header final HttpTokenProvider provider = httpTokenProvider; return () -> { - CharSequence token = provider.getToken(); + // 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; }; 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 9e41b9148..535805317 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 @@ -888,9 +888,9 @@ private void stampTokenIfPending() { // 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 token; + CharSequence pulled; try { - token = httpTokenProvider.getToken(); + pulled = httpTokenProvider.getToken(); } catch (LineSenderException e) { throw e; } catch (RuntimeException e) { @@ -900,6 +900,14 @@ private void stampTokenIfPending() { : 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(); 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 4c2ac548e..e8f19330f 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 @@ -1912,9 +1912,9 @@ private String resolveAuthorizationHeader() { // 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 token; + CharSequence pulled; try { - token = tokenProvider.getToken(); + pulled = tokenProvider.getToken(); } catch (LineSenderException e) { throw e; } catch (RuntimeException e) { @@ -1924,6 +1924,9 @@ private String resolveAuthorizationHeader() { : 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; } 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 index 8b87f82e2..5da4e3f27 100644 --- 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 @@ -329,6 +329,39 @@ public void testMutatedSameInstanceProviderTokenIsRevalidated() throws Exception }); } + @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 HandOffToken(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(() -> { @@ -435,4 +468,47 @@ private static void assertProviderTokenRejected(HttpTokenProvider provider, Stri } } } + + /** + * A provider buffer that hands off its content the moment a full scan of it completes: the first + * traversal reads {@code clean}, and every read after that reads {@code spliced}. That is the shape of a + * reused zero-allocation buffer refreshed by another thread the instant the validating scan finishes - + * the narrowest version of the window, and the one a reader that validates and then re-reads loses. + */ + private static final class HandOffToken implements CharSequence { + private final String spliced; + private CharSequence current; + private boolean handedOff; + + HandOffToken(String clean, String spliced) { + this.current = clean; + this.spliced = spliced; + } + + @Override + public char charAt(int index) { + final char c = current.charAt(index); + if (!handedOff && index == current.length() - 1) { + handedOff = true; + current = spliced; + } + return c; + } + + @Override + public int length() { + return current.length(); + } + + @Override + public CharSequence subSequence(int start, int end) { + return current.subSequence(start, end); + } + + @Override + public String toString() { + // what a StringBuilder-backed buffer does: materialise whatever it currently holds + return current.toString(); + } + } } From 49e0c9b2afd070123c3c1bb8e9c6412b6e0da50b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 15:57:54 +0100 Subject: [PATCH 157/192] Collapse two mutually masking SIGSEGV guards into one that is pinned cancelRow()'s isTokenPending early return and trimContentToLen's contentStart < 0 guard defended the same crash, and cancelRow is trimContentToLen's only caller in this repo, so each hid the other. Reproduced before touching anything: guard removed targeted suite ------------------------- ------------------------------------------- cancelRow's only Tests run: 607, Failures: 0 -- BUILD SUCCESS trimContentToLen's only Tests run: 607, Failures: 0 -- BUILD SUCCESS both SIGSEGV (0xb) ... The forked VM terminated without properly saying goodbye So neither was pinned, either could have been deleted by a refactor with CI green, and the surviving state - a write pointer of contentStart + len == -1 - kills the fork on the next write rather than failing a test. No black-box test can pin either while the other stands: with trimContentToLen guarded, cancelRow's early return and the trim it skips leave identical observable state (state EMPTY, isTokenPending set, getContentStart()/getContentLength() both 0, ptr untouched). Pinning both would have meant a test-only counter on an exported class. So the redundancy goes instead, and what is left is pinned. trimContentToLen's guard is the one that survives, not by coin flip: Request is exported, so an external caller can reach it directly on a header-stage request and only this guard protects them - it was never purely redundant. cancelRow keeps the reasoning as a comment, including "do not re-add a check here", since re-adding one would silently unpin this again. HttpClientRequestTrimTest drives a header-stage request (GET, url, headers, no withContent()) and asserts on the POINTER rather than by writing through it, so a regression names itself instead of killing the fork. It also covers the stale non-zero bookmark rowBookmark carries from the previous request, and that the request still works afterwards - the content section opens, writes land, and a real trim then really rewinds. Counterfactual with the sole remaining guard removed: java.lang.AssertionError: 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 expected:<30827479116> but was:<-1> An assertion, not a crash, which is the other half of the point. Restored: 646/646 across the ILP sender, token-provider, HTTP client, response and Sender suites. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/http/client/HttpClient.java | 13 +++ .../line/http/AbstractLineHttpSender.java | 15 +-- .../client/HttpClientRequestTrimTest.java | 103 ++++++++++++++++++ 3 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientRequestTrimTest.java 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 bb8e6d2c9..cecc41436 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 @@ -585,6 +585,19 @@ 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 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 535805317..1005715bf 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 @@ -487,15 +487,12 @@ public DirectByteSlice bufferView() { @Override public void cancelRow() { validateNotClosed(); - if (isTokenPending) { - // newRequest() left the request at the header stage with the provider token deferred, so - // withContent() has not run and contentStart is still -1 (getContentLength() reads 0): no row - // bytes were written, so there is nothing to trim. trimContentToLen(0) would set the write - // pointer to contentStart + 0 == -1 and the next buffer write would segfault. Just reset the - // row state and leave the token pending for the next row. - state = RequestState.EMPTY; - return; - } + // 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; } 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()); + } + }); + } +} From 90df3721c860d04e92f05c4e340a9694c9c96a31 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 17:18:14 +0100 Subject: [PATCH 158/192] Zero the buffer StringSink abandons when it grows wipe() fills the buffer the sink currently holds, and checkCapacity replaces that buffer: it copies into a larger array and lets the old one go unzeroed. Every generation growth leaves behind therefore 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 a theoretical residue for the sinks wipe() exists for. OidcDeviceAuth's formSink is a default 16-char sink that builds grant_type=refresh_token&refresh_token=&client_id=...&scope=... so it is already holding the entire refresh token by the time the later parameters make it grow again, and each array it hands off carries a full copy. close() wiped only the survivor. The javadoc made this worse by enumerating what wipe() cannot reach - a toString() result, anything downstream wrote elsewhere - without naming the one retention that was inside the sink's own storage. checkCapacity now zeroes the array as it hands off. The WHOLE array, not the live prefix: a sink cleared after holding a long secret keeps that secret past pos, which is the same retention wipe() itself closes. The cost is one more pass over an array this method already copies, on a path amortised O(1) per character, so a long-lived sink pays it only while it reaches steady state. Two tests, both failing without the fix: testGrowthZeroesTheBufferItAbandons captures the array by reflection before the growth that abandons it: ComparisonFailure: the array growth abandoned still holds the secret it carried: SECRET-012345678 expected:<[]> but was:<[SECRET-012345678]> testNoGenerationKeepsTheTokenAfterAFormBodyIsBuiltAndWiped rebuilds the real form body in a default-sized sink, samples the buffer after every write (once the sink moves on, the array it abandoned is unreachable from it - which is exactly why wipe() cannot clean them), then wipes: generation 3 of 5 still holds the refresh token after wipe(); it was abandoned by growth, so wipe() never reached it: grant_type=refresh_token&refresh_token=REFRESH-TOKEN-abcdef0123456789 Full client suite: 3355 run, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/questdb/client/std/str/StringSink.java | 15 ++++ .../test/std/str/StringSinkWipeTest.java | 78 +++++++++++++++++++ 2 files changed, 93 insertions(+) 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 5492342cf..1f4ed18ff 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 @@ -129,6 +129,12 @@ public Utf16Sink put(char c) { * 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); @@ -148,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/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java b/core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java index 8b2c172cc..70e4ae417 100644 --- a/core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java +++ b/core/src/test/java/io/questdb/client/test/std/str/StringSinkWipeTest.java @@ -28,6 +28,10 @@ import org.junit.Assert; import org.junit.Test; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + /** * Covers {@link StringSink#wipe()}, the hygiene primitive the OIDC client uses to stop a token remaining * legible in a reusable sink after the instance that read it is closed. @@ -58,6 +62,80 @@ public void testClearLeavesTheTailLegibleAndWipeDoesNot() { sink.subSequence(0, held).toString().contains("TOKEN")); } + @Test + public void testGrowthZeroesTheBufferItAbandons() throws Exception { + // wipe() can only reach the buffer the sink currently holds. Growth replaces that buffer, so every + // generation left behind used to keep its contents legible on the heap - the collector is under no + // obligation to overwrite them, and a heap dump taken meanwhile shows the lot. + final Field bufferField = StringSink.class.getDeclaredField("buffer"); + bufferField.setAccessible(true); + + StringSink sink = new StringSink(16); + // exactly fills the initial buffer, so no growth has happened yet + sink.put("SECRET-0123456789".substring(0, 16)); + final char[] abandoned = (char[]) bufferField.get(sink); + Assert.assertEquals(16, abandoned.length); + Assert.assertEquals("precondition: the secret really is in this array", + "SECRET-012345678", new String(abandoned)); + + // one more character forces the grow-and-copy + sink.put('9'); + Assert.assertNotSame("precondition: the sink must have moved to a new array", + abandoned, bufferField.get(sink)); + + Assert.assertEquals("the array growth abandoned still holds the secret it carried: " + + new String(abandoned).trim(), + "", new String(abandoned).replace((char) 0, ' ').trim()); + // and the live sink is intact + Assert.assertEquals("SECRET-0123456789", sink.toString()); + } + + @Test + public void testNoGenerationKeepsTheTokenAfterAFormBodyIsBuiltAndWiped() throws Exception { + // The shape that matters: OidcDeviceAuth's formSink is a default 16-char sink that builds the + // refresh POST body. It is already holding the whole refresh token by the time the later parameters + // make it grow again, so each hand-off carried a full copy - and wipe() at close() reached only the + // last one. + final String token = "REFRESH-TOKEN-abcdef0123456789"; + final Field bufferField = StringSink.class.getDeclaredField("buffer"); + bufferField.setAccessible(true); + + StringSink formSink = new StringSink(); + final List generations = new ArrayList<>(); + generations.add((char[]) bufferField.get(formSink)); + + // Sampled after EVERY write, not at the end: once the sink has moved on, the array it abandoned is + // unreachable from it, which is precisely why wipe() cannot clean them and why this has to catch + // each one as it goes. + final String[] body = { + "grant_type=refresh_token", + "&refresh_token=", token, + "&client_id=questdb", + "&scope=openid+profile+email", + }; + for (String part : body) { + formSink.put(part); + final char[] live = (char[]) bufferField.get(formSink); + if (generations.get(generations.size() - 1) != live) { + generations.add(live); + } + } + Assert.assertTrue("precondition: the sink must have grown at least twice while holding the token, " + + "or this test is not exercising the hand-off it is about", + generations.size() >= 3); + + formSink.wipe(); + + for (int i = 0; i < generations.size(); i++) { + final String contents = new String(generations.get(i)); + Assert.assertFalse( + "generation " + i + " of " + generations.size() + " still holds the refresh token after " + + "wipe(); it was abandoned by growth, so wipe() never reached it: " + + contents.replace((char) 0, '.'), + contents.contains(token)); + } + } + @Test public void testWipeLeavesTheSinkUsable() { // it is a hygiene step, not a teardown: the OIDC client wipes on clearCache() and keeps going From 975cdafcf6cac07c160f02acf08a56be2b9cabfb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 17:30:55 +0100 Subject: [PATCH 159/192] Reunite eight doc comments with the members they document A member inserted between a doc comment and its member silently takes the comment's place, and javadoc drops the orphan entirely. Confirmed against the tool rather than assumed - a probe class with one marker per shape: plain javadoc 2 renders javadoc AFTER an annotation 0 renders first of two stacked blocks 0 renders second of two stacked blocks 2 renders Nothing in this build catches it: -Xlint:none on javac and none on the javadoc plugin. The three that were reported: - StringSink: wipe() was inserted between @NotNull and toString(), so the annotation now decorates a void method and wipe()'s own javadoc sits after an annotation and is dropped. Introduced by 90e49e19, whose diff shows the pre-existing "@NotNull @Override public String toString()". Restored, and wipe() moved below toString() into alphabetical order - which is where an alphabetical insert would have put it in the first place, and would have avoided this. - AbstractLineHttpSender: validateRowStarted()'s subclass-ordering contract - call this BEFORE writing a terminator's first byte, or the bytes land in the HTTP header block and obs-fold the Authorization header away - was stranded on terminateRow(). - BackgroundDrainer: the public connectWithDurableAckRetry() lost its whole javadoc, including the @return null contract, to dynamicCredentialAuthDwellNanos(). A sweep for both shapes across main and test found five more of the same defect, each an alphabetical re-sort that moved the member and left the comment behind: - QwpWebSocketSender.newWebSocketClient() (left on closeQuietlyOnError) - QwpWebSocketSender.healPersistedDictionary() (left on dictionaryEntryWireBytes) - CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS (left on DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS) - PersistedSymbolDict.scanAndCopyRecoveredChunks() (left on statLength) - CursorWebSocketSendLoopCatchUpAlignmentTest's assertCatchUpReassembles overload (left on its sibling overload) All eight moved back. The sweep now reports clean across main and test. Rendered before and after, on the real classes rather than the probe: before after StringSink.wipe() 0 2 BackgroundDrainer @return contract 0 1 validateRowStarted() contract 0 2 Comments only, no behaviour change. Full client suite: 3355 run, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../line/http/AbstractLineHttpSender.java | 18 +++--- .../qwp/client/QwpWebSocketSender.java | 56 +++++++++---------- .../client/sf/cursor/BackgroundDrainer.java | 34 +++++------ .../sf/cursor/CursorWebSocketSendLoop.java | 20 +++---- .../client/sf/cursor/PersistedSymbolDict.java | 16 +++--- .../io/questdb/client/std/str/StringSink.java | 10 ++-- ...WebSocketSendLoopCatchUpAlignmentTest.java | 34 +++++------ 7 files changed, 94 insertions(+), 94 deletions(-) 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 1005715bf..386e6353f 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 @@ -1028,15 +1028,6 @@ protected void validateColumnName(CharSequence name) { } } - /** - * 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. - */ /** * Writes the row terminator and closes the row, WITHOUT re-checking that a row was started - the caller * has already done it. @@ -1056,6 +1047,15 @@ protected void terminateRow() { } } + /** + * 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: 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 cb75020d5..eefdcf869 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 @@ -3116,11 +3116,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 @@ -3135,6 +3130,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) { @@ -4585,6 +4585,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. @@ -4609,29 +4632,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; - } - - /** - * 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); - } - private void healPersistedDictionary(PersistedSymbolDict pd) { if (pd == null || !deltaDictEnabled) { return; 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 fff4c5b36..c34f452a6 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 @@ -329,6 +329,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 @@ -364,23 +381,6 @@ public BackgroundDrainer() { * @return a fresh durable-ack-capable client, or {@code null} if * {@link #outcome} has been set to FAILED or STOPPED */ - /** - * 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)); - } - public WebSocketClient connectWithDurableAckRetry() { // run() already set runnerThread; setting it again here is a no-op // on that path but wires up direct callers so requestStop() 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 6e6c697b3..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 @@ -87,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}) @@ -120,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 /** 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/std/str/StringSink.java b/core/src/main/java/io/questdb/client/std/str/StringSink.java index 1f4ed18ff..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 @@ -122,6 +122,11 @@ public Utf16Sink put(char c) { /* Either IDEA or FireBug complain, annotation galore */ @NotNull + @Override + 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 @@ -141,11 +146,6 @@ public void wipe() { pos = 0; } - @Override - public String toString() { - return new String(buffer, 0, pos); - } - private void checkCapacity(int extra) { int len = pos + extra; if (buffer.length >= len) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java index d9aa508d6..ec828aae2 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java @@ -1534,23 +1534,6 @@ private List captureCatchUpFramesWithOneLargeSymbol( return client.capturedFrames; } - /** - * Reassembles the frames captured since the last call through the same - * {@link QwpWireTestUtils#accumulateDeltaDictionary} the end-to-end tests' - * handler uses -- with {@code allowGap=true}, so a hole surfaces as a null - * entry here instead of raising {@code DictionaryGapException} the way a - * real server now would -- and asserts the result is the seeded dictionary, - * dense and in order. - *

    - * This is what frame counting cannot do. A catch-up split ships its chunks as - * {@code [deltaStart, deltaStart+count)} ranges that must tile {@code [0, n)} - * exactly; an off-by-one in the walk's start id keeps the frame COUNT intact - * while overlapping a range (an id silently takes its neighbour's symbol) or - * skipping one (surfaced here as a null entry; against a real server that id - * would instead be REJECTED as a dictionary gap). Comparing the reassembled - * dictionary catches all three shapes -- overlap, gap and shift -- because it - * compares content per id, not just the ranges. - */ /** * As {@link #assertCatchUpReassembles(CatchUpCapturingClient, String...)}, but for * {@link #captureCatchUpFrames} / {@link #captureCatchUpFramesWithOneLargeSymbol}, @@ -1572,6 +1555,23 @@ private static void assertCatchUpReassembles(List frames, int expectedCo } } + /** + * Reassembles the frames captured since the last call through the same + * {@link QwpWireTestUtils#accumulateDeltaDictionary} the end-to-end tests' + * handler uses -- with {@code allowGap=true}, so a hole surfaces as a null + * entry here instead of raising {@code DictionaryGapException} the way a + * real server now would -- and asserts the result is the seeded dictionary, + * dense and in order. + *

    + * This is what frame counting cannot do. A catch-up split ships its chunks as + * {@code [deltaStart, deltaStart+count)} ranges that must tile {@code [0, n)} + * exactly; an off-by-one in the walk's start id keeps the frame COUNT intact + * while overlapping a range (an id silently takes its neighbour's symbol) or + * skipping one (surfaced here as a null entry; against a real server that id + * would instead be REJECTED as a dictionary gap). Comparing the reassembled + * dictionary catches all three shapes -- overlap, gap and shift -- because it + * compares content per id, not just the ranges. + */ private static void assertCatchUpReassembles(CatchUpCapturingClient client, String... expected) { List rebuilt = new ArrayList<>(); for (byte[] frame : client.capturedFrames) { From 675b7866694afb045e3764e6fb549cff63d889ef Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 17:41:28 +0100 Subject: [PATCH 160/192] Say why the error response body could not be read throwOnHttpErrorResponse's catch bound the HttpClientException and threw it away, keeping only the status. The status is the verdict, but why the body read failed is the actionable half, and the shapes that arrive here call for different responses: timed out reading the chunked response body -> the flush timeout peer disconnect [errno=54] -> the connection malformed chunk size -> an intermediary mangling the framing All three used to render identically, on a path that has already disconnected, so an operator got a status and no way to tell them apart. The message now carries the reason: before: Could not flush buffer: could not read the error response body [http-status=401] after: Could not flush buffer: could not read the error response body [http-status=401, reason=timed out [errno=35]] 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. A null message renders as rather than as an empty field - StringSink.put(null) is a silent no-op, which would have produced "reason=]". testDribbledBodyUnderAnErrorStatusStillSurfacesTheStatus already drove this path for the status; it now also requires the reason, and requires it to be the read abort rather than something invented. Without the fix: AssertionError: the reason the body read failed must reach the caller: Could not flush buffer: could not read the error response body [http-status=401] Two things left alone, both pre-existing and outside this change: - flush0's drain catch (line ~751) also binds an unused e. There the flush was a SUCCESS - a 2xx is the commit - so there is no exception to attach the reason to, and the class has no logger to put it in. Adding one is a bigger change than this warrants. - LineSenderException(CharSequence, boolean retryable) discards the flag: there is no retryable field and the class extends RuntimeException directly. Every caller of that constructor is affected, so changing it is a behaviour change, not a cleanup. Full client suite: 3355 run, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/line/http/AbstractLineHttpSender.java | 13 ++++++++++++- .../line/LineHttpSenderErrorResponseTest.java | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 386e6353f..e31e2f9ec 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 @@ -926,8 +926,19 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. 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(']'); + .put(" [http-status=").putAsPrintable(statusCode.asAsciiCharSequence()) + .put(", reason=").put(reason != null ? reason : "") + .put(']'); } } 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 index 305af1a9d..a1d466b89 100644 --- 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 @@ -198,6 +198,13 @@ public void testDribbledBodyUnderAnErrorStatusStillSurfacesTheStatus() throws Ex 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()); From 5ebdaf06e8f7f1e52b071e4fefe65e7e9b08e601 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 17:53:32 +0100 Subject: [PATCH 161/192] Keep the two other things the ILP sender was handed and dropped Both are the same shape as the error-body catch: a value the code receives, binds, and discards, leaving a caller unable to see something the sender already knew. flush0's drain catch -------------------- A body-drain abort after a 2xx bound the HttpClientException and threw it away. The flush itself SUCCEEDED - a 2xx is the commit - so this changes no outcome, but it does drop the connection, because unconsumed bytes would mis-frame the next response on it. Against a server or intermediary that dribbles every response that is one reconnect per flush, and nothing said why. AbstractLineHttpSender had no logger; its sibling channels in this package (PlainTcpLineChannel, DelegatingTlsChannel, UdpLineChannel) all use SLF4J, so it gets one and logs the reason at DEBUG - not WARN, because the handling is correct and a legitimately slow body is not a fault. LineSenderException's retryable flag ------------------------------------ The (CharSequence, boolean retryable) constructor took the flag and assigned nothing: no field, and the class extends RuntimeException directly. Six call sites in AbstractLineHttpSender pass it - a 5xx/429 gets true, a 401/403/405 false - and the classification died at the constructor. That matters because the class javadoc instructs a caller to act on exactly this distinction: "For transient errors: Retry by calling flush() again on the same Sender instance. For permanent errors: Either close and recreate the Sender, or call reset()". The sender computes which one it is and used to keep it to itself, so the documented strategy could not be followed. isRetryable() exposes it. Additive, so no exported signature changes - ExportedApiCompatibilityTest still passes. false means "not classified as retryable", not "proven permanent": the constructors carrying no classification report false, the conservative direction for a caller that retries only on true. Four tests, all failing without the fixes: retryable discarded again: testExplicitClassificationSurvivesConstruction drain reason discarded again: AssertionError: a drain abort that drops the connection must say so; logged: [] The end-to-end retryable test wanted 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 under -ea before the classification is reached. It crafts the chunked response with MockOidcServer.raw instead. Full client suite: 3359 run, 0 failures, 0 errors, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/line/LineSenderException.java | 24 +++++ .../line/http/AbstractLineHttpSender.java | 12 +++ .../line/LineHttpSenderErrorResponseTest.java | 48 +++++++++ .../LineSenderExceptionRetryableTest.java | 102 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionRetryableTest.java 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 e31e2f9ec..82b50f110 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 @@ -59,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; @@ -749,7 +752,16 @@ private void flush0(boolean closing) { 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)) { 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 index a1d466b89..0d58769b0 100644 --- 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 @@ -24,11 +24,15 @@ 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; @@ -166,6 +170,50 @@ public void testDribbledBodyUnderA2xxDoesNotResendTheBatch() throws Exception { }); } + @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(() -> { 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..2b48b8260 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionRetryableTest.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.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()); + } + } + } + }); + } +} From 807f984cf06f36f478ec3527bea043d946df16f1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:14:23 +0100 Subject: [PATCH 162/192] Tidy three hygiene slips in the branch's own files - java.nio.file.Paths was imported and never used in BackgroundDrainerDurableAckRetryTest. The four "Paths" hits in that file are all slotPaths/unavailableSlotPaths identifiers; there is no Paths.get anywhere in it. - OIDCAuthExample carried no licence header. It is the only file in core/src without one that this branch added; the three that remain - Decimal64, Decimal128, Decimal256 - predate it and are left alone. - SEGMENT_SIZE_BYTES = 16384L, five digits with no separator, in the two files this branch added (BackgroundDrainerCredentialOutageReportTest, BackgroundDrainerMidDrainAuthRejectTest). Four pre-existing files spell it the same way and two already use 16_384L; only the branch's own are changed, so this is not unrelated churn on a PR. Comments and constants only. Affected classes: 37 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) --- ...oundDrainerCredentialOutageReportTest.java | 2 +- .../BackgroundDrainerDurableAckRetryTest.java | 1 - ...ckgroundDrainerMidDrainAuthRejectTest.java | 2 +- .../client/test/example/OIDCAuthExample.java | 24 +++++++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) 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 index 8da33aa1f..da7b7ca31 100644 --- 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 @@ -95,7 +95,7 @@ public class BackgroundDrainerCredentialOutageReportTest { 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 = 16384L; + 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"; 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 14abc248d..5d32b2a4f 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 @@ -47,7 +47,6 @@ 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; 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 index cd8d01c54..09a76656f 100644 --- 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 @@ -90,7 +90,7 @@ public class BackgroundDrainerMidDrainAuthRejectTest { 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 = 16384L; + private static final long SEGMENT_SIZE_BYTES = 16_384L; private static final long SF_MAX_TOTAL_BYTES = 1L << 20; private String slotPath; diff --git a/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java index dd5b69ff0..80a1bab6d 100644 --- a/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java +++ b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java @@ -1,3 +1,27 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.example; import io.questdb.client.Sender; From 110775bb1b792253a236c3dc7106a5edbce29311 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 21:43:50 +0100 Subject: [PATCH 163/192] Keep the rotated refresh token a refresh returns tryRefresh() called storeTokens() only when the response carried the token kind getToken() serves, and storeTokens() is the sole writer of this.refreshToken. So a refresh that answered a clean 2xx without the served kind had its whole body discarded, including a rotated refresh_token in it. That response shape is legal and common: RFC 6749 6 makes the field optional, and OIDC Core 12.2 says a refresh response is the token response "except that it might not contain an id_token". A groupsInToken client meets it against any provider that mints an id token only at authorization time -- and the existing testRefreshWithoutIdTokenFallsBackToInteractiveFlow already pins that the client falls back to the device flow there. What it did not pin is the refresh token. Against a ROTATING provider the token we presented is already spent by the time that response arrives, so keeping it means every later refresh replays a dead credential. A reuse-detecting provider answers a replay by revoking the whole token family, which costs the caller the credential outright rather than one failed refresh. Persisted it is worse again: the on-disk entry keeps the burned token, so the next process start adopts it and re-prompts a human who did not need to be asked. adoptRotatedRefreshToken() takes the rotation before the response is dropped, and persists it for the same reason it is adopted. It takes nothing else: the served kind did not arrive, so the cached tokens and the expiry stay put, the entry still reads as expired, tryRefresh() still reports failure, and the caller still falls back to the interactive flow -- now holding a refresh token that works, so the next refresh can succeed on its own. The "clean 2xx, no OAuth error" half of hasRequiredToken becomes isCleanGrant, because that is the question the rotation turns on and it outlives the served-kind test. testRefreshWithoutIdTokenAdoptsRotatedRefreshToken records the refresh_token each refresh puts on the wire and refuses the device endpoint after the first sign-in, so the interactive fallback cannot mint a fresh token and mask which one the refresh path kept. With the adoption removed it fails as the replay it is: expected: but was: OidcDeviceAuthTest and OidcDeviceAuthPersistenceTest: 170 tests, green. --- .../client/cutlass/auth/OidcDeviceAuth.java | 45 +++++++++++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 71 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) 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 index 0286eff31..bf261c629 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1453,6 +1453,37 @@ private boolean adopt(PersistedToken token) { 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); } @@ -1973,11 +2004,15 @@ private boolean tryRefresh() { // 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)) - && isHttpStatusSuccess() - && tokenParser.error.length() == 0; + && isCleanGrant; if (hasRequiredToken) { try { storeTokens(tokenParser, true); @@ -1992,6 +2027,12 @@ && isHttpStatusSuccess() } 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; 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 index e6c2a4a2c..ed1cf1567 100644 --- 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 @@ -3046,6 +3046,63 @@ public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Ex }); } + @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(() -> { @@ -4005,6 +4062,20 @@ private static void parseSplitValue(int cacheSizeLimit, long address, int split, * 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); From d228c965270e021dc3ca1d37872452a3e84c0fac Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 21:48:13 +0100 Subject: [PATCH 164/192] Discard only files the token store itself wrote discardUntrustedDirectoryContents() empties the store directory when restrictToOwner() finds it writable by other local users, because an entry that was sitting there may have been planted rather than written by us. Discarding ALL entries rather than only the current key's is deliberate and stays: the verdict is spent by whoever observes it first, so anything left behind is something no later call can distrust. What it must not do is decide "entry" means "any .json". Every file this store writes is named after a 64-character lowercase-hex identity fingerprint -- tokenFile() builds ".json", writeTemp() asks createTempFile() for ".tmp" -- and nothing outside that shape is ours to delete. The directory being group-writable is what brought us here; it is not a licence to remove an operator's files. The trigger is ordinary rather than exotic. FileTokenStore.at(dir) and questdb.client.oidc.token.store.dir both let an operator name the directory, the class javadoc and the README tell them to use one per application user, and a mkdir under a umask of 002 lands 0775. Point either at a directory that also holds their own JSON and the first getToken() deletes it. The one-shot SLF4J warning that fires alongside is emitted by the same call that does the deleting, so it is a notification rather than a guard -- and the library ships slf4j-api with no binding, so by default it goes nowhere. hasStoreHashPrefix() is the test for "we could have written this", mirroring the hash-prefix scoping sweepTempFiles() already applies. The entry arm additionally requires the exact ".json" length, since a write temp is the only one of our names that carries an infix. testLoadDiscardsOnlyTheStoresOwnFilesFromAWorldWritableDirectory plants three files a stranger owns -- a plain name, a hex name too short to be a fingerprint, and a foreign temp -- alongside a real entry, then loosens the directory. It asserts the real entry still goes and the other three stay. With the shape test removed it fails on the first: a file the store never wrote must survive: my-important-settings.json No uppercase-hex case: the store renders its digests lowercase, but on a case-insensitive filesystem such a name IS the real entry, so asserting it would test the filesystem rather than the filter. Found by writing it, on APFS. FileTokenStoreTest and OidcDeviceAuthPersistenceTest: 107 tests, green. --- .../client/cutlass/auth/FileTokenStore.java | 45 ++++++++++++++++++- .../test/cutlass/auth/FileTokenStoreTest.java | 43 ++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) 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 index 436663c6c..1d3557215 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -147,6 +147,10 @@ public final class FileTokenStore implements TokenStore { 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; @@ -594,6 +598,32 @@ private static void deleteCapturedLock(Path captured) { } } + /** + * 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 @@ -1101,7 +1131,20 @@ private void discardUntrustedDirectoryContents() { try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path entry : stream) { final String name = entry.getFileName().toString(); - final boolean isEntry = name.endsWith(".json"); + // 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 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 index 265019c5d..0ff5e54d2 100644 --- 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 @@ -1437,6 +1437,49 @@ public void testLoadMissingReturnsNull() throws Exception { }); } + @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", From 967a729573313573f3a84065162030e2d0e83ca0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 21:53:03 +0100 Subject: [PATCH 165/192] Namespace the in-process store lock by directory PROCESS_LOCKS serializes same-identity critical sections within a JVM, because two OidcDeviceAuth instances sharing one identity would otherwise run the read-refresh-write concurrently and double-POST a rotating refresh token -- which a reuse-detecting provider answers by revoking the whole family. It was keyed on TokenStoreKey.hash() alone. That hash names a CONFIGURATION. computeHash() takes the client id, both endpoints, the scope, the audience and the groups-in-token flag, and no directory at all. So two FileTokenStore instances over per-user directories -- the shape this class's javadoc and the README both prescribe for signing several application users in at once -- minted the same key and queued on one ReentrantLock while touching entirely different files. The cost is the one the comment above PROCESS_LOCKS already rejects a stripe table for: the lock is held across a whole token-endpoint round trip while the caller also holds its OidcDeviceAuth instance lock, and this acquire has no budget (lockAcquireBudgetMillis bounds the FILE lock only). One user's stalled refresh therefore blocked another user's getToken() on the ILP flush path for that holder's entire worst case. processLockIdentity() namespaces the entry by directory as well. Both halves are load-bearing: the fingerprint alone over-serializes as above, the directory alone would let two identities in one directory race. The namespace is normalized once in the constructor rather than per acquire, because "a" and "./a" must not mint two locks over one directory -- under-serializing is the dangerous direction, not the merely slow one. toAbsolutePath().normalize() and not toRealPath(): the directory may not exist yet, since 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. testSameIdentityInDifferentDirectoriesDoesNotSerialize is 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. Same CyclicBarrier trick -- it trips only when both callers are inside at once, which two callers sharing one lock can never be. With the directory dropped from the key it fails as the head-of-line block it is, taking 20s to time out against 3.6s green: expected null, but was: It resolves two distinct subdirectories rather than calling storeDir() twice; that helper returns one fixed path, and two stores over ONE directory are exactly the case that must keep serializing. Found by writing it -- the first draft used storeDir() and failed with the fix in place, for the right reason. FileTokenStoreTest and OidcDeviceAuthPersistenceTest: 108 tests, green. --- .../client/cutlass/auth/FileTokenStore.java | 47 +++++++++++--- .../test/cutlass/auth/FileTokenStoreTest.java | 62 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) 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 index 1d3557215..0529a6ce7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -212,6 +212,14 @@ public final class FileTokenStore implements TokenStore { 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) { @@ -263,6 +271,7 @@ public FileTokenStore(Path directory, long lockAcquireBudgetMillis, long lockSta 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; } @@ -363,7 +372,14 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { } // Retain before the acquire and release in the outermost finally, so every exit - the interrupted // acquire below included - gives the claim back exactly once. - final ProcessLock processLock = retainProcessLock(key); + // 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 @@ -377,7 +393,7 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // learns through the false return (OidcDeviceAuth turns it into a credential failure), while // leaving the flag set would break every later blocking call on this thread, including the // teardown the interrupt was sent to enable. - releaseProcessLock(key); // the acquire never happened, so give the claim straight back + releaseProcessLock(lockIdentity); // the acquire never happened, so give the claim straight back return false; } try { @@ -460,7 +476,7 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { } } finally { processLock.lock.unlock(); - releaseProcessLock(key); + releaseProcessLock(lockIdentity); } } @@ -794,15 +810,15 @@ private static void putStringMember(StringSink sink, String name, CharSequence v // 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(TokenStoreKey key) { - PROCESS_LOCKS.computeIfPresent(key.hash(), (identity, held) -> --held.users == 0 ? null : held); + 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(TokenStoreKey key) { - return PROCESS_LOCKS.compute(key.hash(), (identity, existing) -> { + 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; @@ -1202,6 +1218,23 @@ 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 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 index 0ff5e54d2..aa46d9db0 100644 --- 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 @@ -430,6 +430,68 @@ public void testProcessLocksDoNotGrowWithTheIdentityCount() throws Exception { }); } + @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(() -> { From 56eecc75cfe286703b450256911f6f1fbcea08bb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 21:57:55 +0100 Subject: [PATCH 166/192] Stop signIn() ignoring and erasing a cancellation signIn() checked the interrupt flag once, on entry. Everything after that guard is network work: the silent refresh is a round trip bounded by four times httpTimeoutMillis plus an OS connect stall. An interrupt arriving in that window reached nothing, so the flow went on to launch a browser and enter a poll loop that runs to the device-code lifetime -- up to 30 minutes -- on a thread whose owner had already asked it to stop. Worse, the loop then destroyed the evidence. sleepBetweenPolls used Os.sleep, which catches InterruptedException, recomputes its deadline and keeps sleeping; Thread.sleep clears the flag when it throws. So the caller's cancellation was not merely ignored, it was consumed. signIn() returned with Thread.interrupted() reading false, and the shutdown path that raised it -- an ExecutorService.shutdownNow(), a Future.cancel, QWP's ConnectCancellation -- was left believing the thread was never cancelled. This is the outcome the comment above the entry guard already describes as fixed ("A caller who cancelled got a browser prompt and a thread parked for half an hour"), and it states the invariant absolutely: "the flag is the caller's cancellation signal and must survive this call, exactly as getToken() and FileTokenStore.load()/save() preserve it". Only a carried interrupt was actually honoured. Two gaps, both closed: - a re-check before the interactive phase, because a cancellation landing during the refresh is the ordinary case rather than a narrow race -- a caller gives up precisely when a refresh is dragging; - Thread.sleep in the poll loop, with the flag restored and the step abandoned, so the loop notices a cancellation and leaves the signal intact for whoever raised it. throwIfInterrupted() uses isInterrupted(), never interrupted(), for the reason above; clearing the flag would make this guard one more cause of the failure it exists to stop. close() from another thread remains the documented way to abort an in-flight sign-in and is unchanged; this is about the caller's own thread being cancelled, where close() is not a lever the caller has. Two regression tests, one per gap. testInterruptDuringTheRefreshDoesNotStartTheDeviceFlow holds a refresh open until the worker has been interrupted; with the gate removed it fails on "signIn() must abandon a cancelled sign-in". testInterruptDuringThePollLoopAbandonsItAndKeepsTheFlag interrupts from the prompt, which runs on the sign-in thread immediately before the loop; with Os.sleep restored it does not fail an assertion at all -- it runs out the 30s test timeout, which is the symptom, shortened only because the device code in the fixture is good for 1800s. OidcDeviceAuthTest, OidcDeviceAuthPersistenceTest and WebSocketCredentialCancellationTest: 174 tests, green. --- .../client/cutlass/auth/OidcDeviceAuth.java | 46 ++++++- .../test/cutlass/auth/OidcDeviceAuthTest.java | 113 ++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) 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 index bf261c629..f7e7f6391 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -688,6 +688,13 @@ public String signIn() { 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; @@ -1900,14 +1907,29 @@ private String selectToken() { } 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; Os.sleep ignores thread - // interrupts, so polling the closed flag is the only way to stay responsive to cancellation + // 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); - Os.sleep(slice); + 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; } } @@ -1969,6 +1991,22 @@ private void throwIfClosed() { } } + /** + * 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. 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 index ed1cf1567..1203d7efc 100644 --- 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 @@ -3046,6 +3046,119 @@ public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Ex }); } + @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(() -> { From 79e31ca1b525ba9676735344c9a97c157b829c2a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 22:00:00 +0100 Subject: [PATCH 167/192] Assert the retryable half of isRetryable(), not just the other one The only test driving the new accessor through a real Sender was testADefinitiveStatusFromTheSenderIsNotRetryable, which ends at assertFalse on a 401. That assertion cannot fail for the reason it claims to test: the three LineSenderException constructors that carry no classification also hard-code retryable=false, so it reads the same whether the sender classifies correctly or has stopped classifying altogether. Its sibling asserts that same false from those constructors, which makes the two indistinguishable under the regression. Both production sites that pass true were therefore unasserted: the give-up throw after the retry budget is spent on a retryable status (5xx, 429, 421, 404), and the one after it is spent on a transport error. Flipping either literal to false failed nothing. That matters because the accessor is the one the class documentation tells callers to branch on -- retry flush() on a transient failure, close or reset() on a permanent one -- and there is no other programmatic way to make that call. A caller doing what the javadoc says would, on a lost classification, treat a 503 from a restarting server as permanent and tear down a healthy sender, discarding the buffered batch. Two tests, one per site: a chunked 503 with the retry budget spent, and an unreachable endpoint. No production change; the classification is correct today and these pin it. Mutation proof, flipping both literals to false: Tests run: 5, Failures: 2 testARetryableStatusFromTheSenderIsRetryable testATransportFailureFromTheSenderIsRetryable and testADefinitiveStatusFromTheSenderIsNotRetryable passes throughout, which is the point. The 503 fixture is chunked because flush0 asserts response.isChunked() on the error branch and MockOidcServer.json writes Content-Length. LineSenderExceptionRetryableTest, LineSenderExceptionTest and LineHttpSenderErrorResponseTest: 27 tests, green. --- .../LineSenderExceptionRetryableTest.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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 index 2b48b8260..99e9423c4 100644 --- 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 @@ -99,4 +99,76 @@ public void testADefinitiveStatusFromTheSenderIsNotRetryable() throws Exception } }); } + + @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()); + } + } + }); + } } From 7a66cb0f9c027aba44e56701ed91573e012d94d6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:10:19 +0100 Subject: [PATCH 168/192] Bound the response head read on elapsed time ResponseHeaders.await(int) passed its timeout parameter straight into recvOrDie on every pass, so each socket read re-armed the full budget and the call as a whole had no bound. AbstractResponse.recv and AbstractChunkedResponse.recv already carry the whole-call deadline this adds; await() reads the HEAD of the same response over the same socket and was left out. That is not a slower version of the same thing, it is unbounded. recvOrDie returns 0 whenever a read yields no application bytes, a 0 leaves totalBytesReceived unmoved, and an unmoved counter neither advances the header parser nor fills its 4096-byte buffer -- so the "header is too large" escape never fires either. The loop simply runs. It matters because OidcDeviceAuth reads this head from an identity provider, on the getToken() path an ILP sender built with httpTokenProvider calls once per flush (postForm at the token endpoint, fetchJson during discovery). requireSecureIdpEndpoint forces https there, and a partial TLS record decrypting to no application bytes is exactly the zero-length read above -- JavaTlsClientSocket.recv returns 0 on BUFFER_UNDERFLOW. A stalled or hostile provider could therefore hang a producer's flush thread indefinitely, with no exception and no log. Four places document a bound that this defeated: getToken()'s javadoc ("the send, response wait and body parse ... each bounded by httpTimeoutMillis"), the HttpTokenProvider contract, LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE, and Builder.build()'s FileTokenStore staleness floor, which throws to enforce a multiple of a figure the response wait did not respect. The bound reuses the sibling methods' shape exactly: a positive timeout bounds the whole call, a non-positive one keeps the legacy behaviour. Against a dribbling peer the shrinking per-pass budget starves ioWait's poll first, so the throw arrives from there; against reads that yield no application bytes at all -- which consume no budget -- the loop's own deadline check fires. Neither can keep running, which is the point. MockOidcServer gains dribbleHead() for the test: the body-dribbling dribble() cannot reach this path, because a client only starts reading a body once the head has parsed. The head it emits stays well-formed throughout, so the client aborts on its deadline rather than on a parse error, which would prove nothing. With the fix the test aborts in under a second; with await() reverted it runs until the 30s test timeout. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/http/client/HttpClient.java | 20 +++- .../test/cutlass/auth/MockOidcServer.java | 39 ++++++++ .../HttpClientResponseHeadTimeoutTest.java | 95 +++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/http/client/HttpClientResponseHeadTimeoutTest.java 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 cecc41436..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 @@ -934,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/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java index c995eee1c..6e804ddc2 100644 --- 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 @@ -119,6 +119,18 @@ public static MockResponse dribble(int status) { 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; @@ -318,6 +330,32 @@ private static void writeResponse(OutputStream out, MockResponse response) throw } 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 @@ -419,6 +457,7 @@ public static class MockResponse { final boolean chunked; final int status; boolean dribble; + boolean dribbleHead; boolean dropConnection; long oversizedBodyBytes; String rawResponse; 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); + } + } + } +} From 2720bbfeefab53c4fb56d26b83e64d3ab4a6058f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:16:09 +0100 Subject: [PATCH 169/192] Derive the OIDC transport budgets from its timeout OidcDeviceAuth built every HTTP client from DefaultHttpClientConfiguration.INSTANCE, which answers 0 for the connect timeout and 600s for the request timeout. 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 connectTimeout > 0 ? connectTimeout : defaultTimeout so the handshake alone got a 600s budget -- derived from nothing the caller set. Neither MAX_HTTP_TIMEOUT_MILLIS (120s) nor the lockStaleMillis floor Builder.build() enforces could constrain it, because neither is on that path. requireSecureIdpEndpoint forces https for the identity provider, so this is the ordinary shape of a refresh, not an edge case. The cost is not a slow request. A silent refresh runs inside FileTokenStore's cross-process lock, whose file is stamped once at creation and never re-stamped, so a hold that outruns DEFAULT_LOCK_STALE_MILLIS (600s) is judged abandoned and stolen by a peer -- stealIfStale's capture-verify confirms the stamp is UNCHANGED, which is exactly what a live holder's lock looks like. Both holders then POST the same parent refresh token, and an identity provider with reuse detection (Auth0's default, Keycloak "Revoke Refresh Token", Okta) answers by revoking the whole family. On a headless producer ingestion stops until a human re-runs the device flow. 600s of handshake against a 600s window leaves negative headroom once the TCP connect ahead of it is counted, and build()'s floor check passed throughout (600_000 >= 120_000), so it read as reassurance. httpConfig(int) now derives both budgets from one figure: the instance's httpTimeoutMillis for its own clients, DEFAULT_HTTP_TIMEOUT_MILLIS for the discovery clients, which run before an instance exists but read /settings and .well-known from the same untrusted position. That bounds the TCP connect as well as the handshake, leaving only DNS resolution to the OS, and makes the "up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x httpTimeoutMillis" figure -- quoted by the lock-stale floor, acquireForGetToken's wait cap and getToken()'s javadoc -- true of the code rather than merely asserted by it. Six comments and the design doc claimed the connection phase was the OS's to bound and told readers to size the staleness window for a connect stall on top of the floor. They now say what the code does, and the design doc states bounding the connect and handshake as a client MUST rather than describing the gap as inherent -- it is the contract the Python client mirrors, and a client that leaves either unbounded reopens the double-POST above. Asserted on the configuration, not end to end: a real handshake stall needs a certificate and the client's test tree has none, which is why OidcDeviceAuthTlsTest lives in the Enterprise tree. Reverting httpConfig() to the shared INSTANCE fails both new tests by name (expected 7777, was 0). Co-Authored-By: Claude Opus 5 (1M context) --- .../io/questdb/client/HttpTokenProvider.java | 6 +- .../client/cutlass/auth/OidcDeviceAuth.java | 95 +++++++++++++----- .../OidcDeviceAuthTransportBudgetTest.java | 98 +++++++++++++++++++ design/oidc-token-persistence.md | 22 ++--- 4 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTransportBudgetTest.java diff --git a/core/src/main/java/io/questdb/client/HttpTokenProvider.java b/core/src/main/java/io/questdb/client/HttpTokenProvider.java index 62e21de76..cf28bbf50 100644 --- a/core/src/main/java/io/questdb/client/HttpTokenProvider.java +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -43,9 +43,9 @@ * 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 OS, not by the client timeout - so a black-holed token endpoint can stall a refresh - * for the OS connect timeout (commonly ~2 minutes on Linux). A producer sizing flush backpressure - * against this call should expect that worst case. An exception from {@link #getToken()} fails the + * 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) 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 index f7e7f6391..c860004e4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -94,8 +94,9 @@ * 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)} - though an unreachable - * endpoint's connect is bounded by the OS, not by it - plus, with a coordinating {@link TokenStore}, a brief + * 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 @@ -125,6 +126,9 @@ public class OidcDeviceAuth implements QuietCloseable { 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 @@ -135,7 +139,6 @@ public class OidcDeviceAuth implements QuietCloseable { // 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); - private static final HttpClientConfiguration HTTP_CONFIG = DefaultHttpClientConfiguration.INSTANCE; // 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 @@ -146,10 +149,10 @@ public class OidcDeviceAuth implements QuietCloseable { 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. NOTE this multiple does NOT cover the connection phase that precedes the send - DNS - // resolution, the TCP connect, and the TLS handshake are NOT bounded by httpTimeoutMillis (the OS bounds the - // connect instead). build() requires the FileTokenStore staleness window to exceed this multiple as a floor; - // the default window adds ample headroom for a typical connection stall on top of it (see build()) + // 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 @@ -161,7 +164,7 @@ public class OidcDeviceAuth implements QuietCloseable { * 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 - * the OS TCP-connect timeout against a black-holed endpoint. + * 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 @@ -183,8 +186,8 @@ public class OidcDeviceAuth implements QuietCloseable { // 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 (DNS + TCP connect + TLS) is bounded by the OS, not by this, and the default staleness window - // leaves headroom for it (see Builder.build()) + // 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 @@ -208,6 +211,8 @@ public class OidcDeviceAuth implements QuietCloseable { 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; @@ -264,6 +269,10 @@ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig, Endpoi 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; @@ -481,8 +490,8 @@ public void clearCache() { * 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 - - * DNS, TCP connect, TLS handshake - is bounded by the OS, not by that timeout, so a black-holed token - * endpoint can hold the lock, and this {@code close()}, for the OS connect timeout (commonly ~2 minutes on + * 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, @@ -550,11 +559,11 @@ public String getAuthorizationHeaderValue() { * 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 - * resolution, the TCP connect and the TLS handshake - is bounded by the OS, not by httpTimeoutMillis, so an - * unreachable (black-holed) token endpoint can stall this refresh for the OS TCP-connect timeout (commonly - * ~2 minutes on Linux) rather than 30s. That is the "quick silent refresh" the {@code HttpTokenProvider} - * contract permits on the flush path, not an unbounded interactive wait - but a producer sizing backpressure - * against this call should expect that OS-bounded connect stall, not a hard 30s cap. + * 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 @@ -838,8 +847,8 @@ private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) { private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError, String statusError) { HttpClient client = endpoint.isTls - ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) - : HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); + ? 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 @@ -903,6 +912,38 @@ private static int hexValue(char c) { 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 @@ -1512,12 +1553,12 @@ private long effectiveSkewMillis() { private HttpClient httpClient(boolean isTls) { if (isTls) { if (tlsClient == null) { - tlsClient = HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig); + tlsClient = HttpClientFactory.newTlsInstance(clientConfig, tlsConfig); } return tlsClient; } if (plainClient == null) { - plainClient = HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); + plainClient = HttpClientFactory.newPlainTextInstance(clientConfig); } return plainClient; } @@ -2226,12 +2267,12 @@ public OidcDeviceAuth build() { // 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. This is a FLOOR, not the whole story - the - // connection phase (DNS + TCP connect + TLS) that precedes the send is bounded by the OS, not by - // httpTimeoutMillis, so the staleness window must also clear a connection stall on top of this floor. - // The default 600s window leaves ~120s of headroom over the floor even at the 120s timeout cap, which - // covers a typical connection stall; a caller raising httpTimeoutMillis should raise lockStaleMillis - // to keep that headroom. A non-coordinating TokenStore is exempt - it takes no lock. + // 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(); 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/design/oidc-token-persistence.md b/design/oidc-token-persistence.md index 342d56c5c..5224f3a3b 100644 --- a/design/oidc-token-persistence.md +++ b/design/oidc-token-persistence.md @@ -479,17 +479,17 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i with short backoff up to a small acquire budget (~3s); if it still cannot be acquired, **proceed without it** (degrade to Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned and stolen, so a crashed holder cannot wedge others. The window - must dominate the worst-case time a live holder can hold the lock. That worst case has two - parts: the refresh I/O under the lock — send + await + parse, plus a body drain on a parse - failure, each separately bounded by the HTTP timeout (capped at 120s), so up to ~4×120s = - ~480s — **plus the connection phase that precedes the send** — DNS resolution, the TCP - connect, and the TLS handshake — which is **not** bounded by the HTTP timeout (the OS bounds - the connect instead; a black-holed connect can run to the OS TCP-connect timeout, commonly - ~2 minutes). So size the window above ~4×HTTP-timeout **plus a generous connection-stall - allowance**, never just ~4×HTTP-timeout; the interactive wait is never held under the lock. - 10 minutes clears ~480s with ample headroom for a typical connection stall; a client that - raises the HTTP timeout must raise this window in step. A client MUST NOT advertise a tighter - guarantee than this (an earlier draft claimed ~480s alone, omitting the connection phase). + must dominate the worst-case time a live holder can hold the lock. That worst case is the + refresh I/O under the lock — send + await + parse, plus a body drain on a parse failure, each + separately bounded by the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s. **A client + MUST also bound the connection phase that precedes the send** — the TCP connect and the TLS + handshake — by the same HTTP timeout. Neither is bounded by it automatically: a client that + leaves the connect to the OS, or sizes the TLS handshake off a transport default, can hold + the lock far past the staleness window and have a peer steal it from under a live refresh, + at which point both replay the same rotating refresh token. Only DNS resolution is left to + the OS. So size the window above ~4×HTTP-timeout plus a DNS allowance; the interactive wait + is never held under the lock. 10 minutes clears ~480s with ample headroom; a client that + raises the HTTP timeout must raise this window in step. - **An empty/unstamped lock is reclaimable on a short grace, not the full staleness window.** The exclusive create and the stamp write are two operations on one open handle, so the file **does exist empty** between them. That window is small — no I/O sits between the two — but it From 930e5681e56afe40cb81d1c8904873abe4c5fcbe Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:22:48 +0100 Subject: [PATCH 170/192] Keep the interrupt a cancelled lock wait consumed FileTokenStore.inLock caught InterruptedException from both of its waits -- lockInterruptibly for the in-process lock, and acquireLock's poll for the cross-process one -- and returned false without re-asserting the flag. The reasoning was that consuming the signal was "acting on it", and that leaving it set would break the teardown the interrupt was sent to enable. Consuming it is what made the two outcomes indistinguishable. A bare false is exactly what a refresh that RAN and FAILED returns, and OidcDeviceAuth acts on the difference: - signIn() reads false as a failed refresh and answers by starting the INTERACTIVE device flow. That launches a browser and then polls to the device-code lifetime (up to 30 minutes) on Os.sleep, which ignores interrupts -- so the owner that cancelled the thread cannot get it back and shutdown never completes. The guard meant to stop this, throwIfInterrupted at the top of the flow, reads the flag inLock had already cleared. Its own comment says a cancellation landing inside the refresh is "the common case, not a narrow race". - getToken() arms refreshFailedAtMillis on a refresh that never happened. That field is INSTANCE state, so one cancelled caller failed every other producer sharing the OidcDeviceAuth for the next five seconds -- with "the cached token expired and could not be refreshed ... call signIn()", sending an operator to re-authenticate over a credential that is fine and an identity provider that was never contacted. ConnectCancellation.cancel() interrupts credential pulls by design on every close, so this half was routine. The inconsistency inside the class was the tell: load() and save() already save and restore the flag around their I/O. Only inLock swallowed it. Both catches now re-assert before returning, and each does so past its own interruptible I/O -- the process-lock arm touches only a ConcurrentHashMap afterwards, and the acquireLock arm implies nonce == null, so the finally skips releaseLock. getToken() checks the flag after a false return and reports the cancellation instead of arming the latch; signIn() needed no change once the flag survives. TokenStore's SPI javadoc now states the obligation, since a third-party store that consumes the interrupt reopens both failures. testInLockAbandonsFileLockWaitOnInterrupt asserted the old behaviour outright and now asserts the new one; its sibling gains the matching check. Two OidcDeviceAuth tests cover the caller half through a FakeTokenStore that models a conformant store, so the two halves fail independently: reverting the store restores 3 FileTokenStoreTest failures, and reverting getToken() reproduces the misleading "call signIn()" message verbatim. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 25 ++++++- .../client/cutlass/auth/OidcDeviceAuth.java | 12 +++ .../client/cutlass/auth/TokenStore.java | 19 +++-- .../test/cutlass/auth/FileTokenStoreTest.java | 21 +++++- .../auth/OidcDeviceAuthPersistenceTest.java | 75 +++++++++++++++++++ 5 files changed, 141 insertions(+), 11 deletions(-) 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 index 0529a6ce7..8533dbdf7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -389,11 +389,20 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { processLock.lock.lockInterruptibly(); } catch (InterruptedException e) { // Interrupted WAITING for the process lock: a live cancellation, acted on by abandoning the - // refresh. Not re-asserted - the signal has been consumed by doing what it asked. The caller - // learns through the false return (OidcDeviceAuth turns it into a credential failure), while - // leaving the flag set would break every later blocking call on this thread, including the - // teardown the interrupt was sent to enable. + // 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 { @@ -442,6 +451,14 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { // 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(); 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 index c860004e4..5f372eefa 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -621,6 +621,18 @@ public String getToken() { 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) { 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 index e5275e208..a9ed9f6d2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -82,11 +82,20 @@ public interface TokenStore { * {@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}. 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. The - * {@code false} return reads as "no refresh happened", which {@code OidcDeviceAuth} already handles. + * 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 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 index aa46d9db0..9397a7a4b 100644 --- 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 @@ -1055,8 +1055,15 @@ public void testInLockAbandonsFileLockWaitOnInterrupt() throws Exception { 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()); - Assert.assertFalse("an interrupt that arrived during the wait is consumed by acting on it, so it " - + "cannot go on to break the teardown it was sent to enable", flagLeftSet.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)); }); } @@ -1090,6 +1097,7 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { AtomicBoolean ran = new AtomicBoolean(); AtomicReference result = new AtomicReference<>(); + AtomicBoolean flagAfterReturn = new AtomicBoolean(); AtomicReference waiterError = new AtomicReference<>(); Thread waiter = new Thread(() -> { try { @@ -1097,6 +1105,10 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { 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); @@ -1122,6 +1134,11 @@ public void testInLockAbandonsProcessLockWaitOnInterrupt() throws Exception { 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); 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 index 119fb4d4d..e84b19cea 100644 --- 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 @@ -108,6 +108,73 @@ public void testAdoptedTokenNearExpiryStillRefreshesOnFlushPath() throws Excepti }); } + @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(() -> { @@ -1578,6 +1645,10 @@ private static final class FakeTokenStore implements TokenStore { 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 @@ -1601,6 +1672,10 @@ public boolean inLock(TokenStoreKey key, CriticalSection action) { 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; From 71eadd7433a0f684ba668ca3591ba0a30d02f148 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:27:46 +0100 Subject: [PATCH 171/192] Break a recovery delegate stuck in a credential pull buildManagedSlotSender applies the token provider on the forRecovery leg too, so a startup-recovery delegate now performs a synchronous credential pull before it connects. Neither stop lever could reach it: PoolHousekeeper.stop() and SenderPool.stopStartupRecoveryDriver() both set a flag and join for STOP_TIMEOUT_MILLIS (2s) without interrupting, and the flag is only read BETWEEN recovery steps. The pull is not a 2s-shaped wait. OidcDeviceAuth.getToken() documents up to four times httpTimeoutMillis behind a peer's refresh, and FileTokenStore's in-process lock wait carries no budget at all -- it is held across a whole token-endpoint round trip. close() therefore returned while the recoverer was still parked, holding its store-and-forward slot flock. An immediate reopen then fails with "sf slot already in use", and the detached build's engine, its mmaps and its I/O thread are leaked. That is precisely the window this pool's per-slot ids exist to eliminate, and it is the same hazard the drain_orphans(false) forced on recovery builds was added to avoid -- the comment beside it spells out the consequence in full, then the provider was wired in one line below. Both stop levers now escalate: join, and if the thread is still alive, interrupt and join again. Every wait on that path honours it -- acquireForGetToken polls a timed tryLock, and FileTokenStore's two lock waits abandon and re-assert the flag as of the previous commit, which is what makes the interrupt a usable lever here rather than a signal the store would swallow. The pull throws, runStartupRecoveryStep's caller swallows it (recovery is best-effort by design), and the loop reaches its stop check and releases the flock before close() returns. The residual-window notes in three places claimed a black-holed connect was the only overrun left. They now say what is true: the credential pull is broken by the interrupt, and the connect survives it because it blocks in a syscall no interrupt breaks. The test reuses the existing two-phase recovery harness -- strand unacked frames against a silent server, then open a second pool over the same sf_dir -- with a provider that parks for five minutes. Reverting both escalations fails it by name; close() returns in ~2.5s either way, so the elapsed bound is not what catches it, the un-interrupted provider is. Co-Authored-By: Claude Opus 5 (1M context) --- .../questdb/client/impl/PoolHousekeeper.java | 36 +++++++-- .../io/questdb/client/impl/SenderPool.java | 14 +++- .../impl/SenderPoolSfTokenProviderTest.java | 81 +++++++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) 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..91ac313aa 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,24 @@ 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. Every wait on that path is 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. + thread.interrupt(); + thread.join(STOP_TIMEOUT_MILLIS); + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } @@ -84,9 +105,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/SenderPool.java b/core/src/main/java/io/questdb/client/impl/SenderPool.java index b902d7d97..aebb95c97 100644 --- a/core/src/main/java/io/questdb/client/impl/SenderPool.java +++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java @@ -760,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 @@ -1707,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(); } diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java index a36bc9f16..4340daa9e 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTokenProviderTest.java @@ -43,7 +43,9 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +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; @@ -125,6 +127,85 @@ public void testSfPooledSendersCarryTheProviderToken() throws Exception { }); } + @Test(timeout = 60_000) + public void testCloseBreaksARecoveryDelegateStuckInACredentialPull() throws Exception { + // A recovery build pulls a credential before it connects, and that pull can block far longer than + // close()'s join: OidcDeviceAuth.getToken() documents a wait of up to four times httpTimeoutMillis + // behind a peer's refresh, and FileTokenStore's in-process lock wait has no budget at all, against a + // PoolHousekeeper.STOP_TIMEOUT_MILLIS of 2s. The stop flag reaches the recovery loop only BETWEEN + // steps, so it cannot reach a step parked inside the pull. + // + // Returning from close() anyway leaves the recoverer holding its slot flock, which is what the + // pool's per-slot ids and the drain_orphans(false) forced on recovery builds exist to prevent: an + // immediate reopen fails with "sf slot already in use", and the detached build's engine, mmaps and + // I/O thread are leaked. So the stop path escalates to an interrupt, which every wait on that path + // honours. + TestUtils.assertMemoryLeak(() -> { + // Phase 1 -- strand unacked frames on disk, so phase 2 has recovery work to do. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + silent.getPort() + ";sf_dir=" + sfDir + ";" + + "sender_pool_min=1;sender_pool_max=1;" + + "query_pool_min=0;query_pool_max=1;" + + "close_flush_timeout_millis=500;"; + try (QuestDB db = QuestDB.connect(cfg, () -> "PHASE1-TOKEN")) { + try (Sender s = db.borrowSender()) { + for (int i = 0; i < 3; i++) { + s.table("recover").longColumn("v", i).atNow(); + s.flush(); + } + } + } + } + Assert.assertTrue("unacked data must persist on disk for recovery to have work", + hasSegmentFile(sfDir + "/default-0")); + + // Phase 2 -- a provider that parks the way a contended token-store lock wait does. + CountDownLatch pullEntered = new CountDownLatch(1); + AtomicBoolean pullInterrupted = new AtomicBoolean(); + HttpTokenProvider blockingProvider = () -> { + pullEntered.countDown(); + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(5)); + } catch (InterruptedException e) { + pullInterrupted.set(true); + Thread.currentThread().interrupt(); + // what OidcDeviceAuth.getToken() does on a cancelled wait: report, do not hang + throw new RuntimeException("credential pull cancelled"); + } + return "NEVER-ARRIVES"; + }; + + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ack.getPort() + ";sf_dir=" + sfDir + ";" + + "sender_pool_min=0;sender_pool_max=1;" + + "query_pool_min=0;query_pool_max=1;"; + + QuestDB db = QuestDB.connect(cfg, blockingProvider); + Assert.assertTrue("the recovery delegate must reach the credential pull", + pullEntered.await(20, TimeUnit.SECONDS)); + + long startNanos = System.nanoTime(); + db.close(); + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + + // The load-bearing assertion. Without the escalation close() joins its budget, gives up and + // returns with the pull still parked -- so this stays false and the flock is still held. + Assert.assertTrue("close() must interrupt a recovery delegate parked in a credential pull, " + + "or it returns while that delegate still holds the slot flock", + pullInterrupted.get()); + // Bounded well above the two joins so a loaded box does not turn this red, and far below + // the provider's 5-minute park, which is what an un-escalated close() would wait out. + Assert.assertTrue("close() must not wait out the parked pull; took " + elapsedMillis + "ms", + elapsedMillis < 30_000); + } + }); + } + @Test public void testSfStartupRecoveryDelegateCarriesTheProviderToken() throws Exception { // The forRecovery leg of buildManagedSlotSender. A recovery delegate replays From 26744e7ba72d9c0328740027f311e2330a070494 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:30:16 +0100 Subject: [PATCH 172/192] Retry a store whose directory was transiently unusable FileTokenStore.load caught ensureDirectory()'s IOException and answered null. load()'s own contract makes null and a throw mean opposite things: Returning null is the definitive answer, and ends the reads for the life of that OidcDeviceAuth. Throwing is not: it reads as a transient fault and is retried. What ensureDirectory() reports there 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, or restrictToOwner failing to read back the permissions it just set. Answering null told every later call that a store holding a perfectly good refresh token was empty. Nothing recovers from that inside the process. maybeLoadFromStore latches storeLoadAttempted on a clean return, signIn() clears the two back-off fields but not the latch, and clearCache() sets it. So a momentary mount fault at the first getToken() cost the process its persistence permanently: it re-runs the interactive device flow with a valid refresh token sitting on disk, and for the headless getToken() consumer this feature exists to serve that is a hard failure -- "no token has been obtained yet; call signIn()", which the caller cannot act on -- until a restart. Two things already disagreed with it inside the same class. The very next arm throws for readBounded's IOException, and save() lets this same exception propagate, which is why writes retried while reads latched. load() now throws too, and the retry/back-off machinery built for exactly this fault gets to do its job. The test uses the fixture testInLockDegradesWhenDirectoryUnusable already established -- a regular file standing where the store directory's parent must be. The nearest existing coverage, testTransientStoreLoadFailureIsRetriedNotLatched, drives a throwing FakeTokenStore, so it asserts the CALLER retries a throw and never reached the real store's swallow arm; reverting the change leaves it green and fails this one with "returned null". Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/FileTokenStore.java | 13 ++++++++- .../test/cutlass/auth/FileTokenStoreTest.java | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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 index 8533dbdf7..c1e110479 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java @@ -520,8 +520,19 @@ public PersistedToken load(TokenStoreKey key) { 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"); - return null; + 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 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 index 9397a7a4b..97217fda3 100644 --- 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 @@ -1673,6 +1673,34 @@ public void testWorldWritableVerdictSurvivesASaveTouchingTheDirectoryFirst() thr }); } + @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", From 43db382efa129347bc0aaf1d6defe90a121c79e7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:32:08 +0100 Subject: [PATCH 173/192] Cover lazy_connect crossed with a token provider lazy_connect and httpTokenProvider were each tested alone; their combination decides who sees a credential failure at startup and nothing drove them together. The contract is that connectivity and credential errors are the caller's problem only DURING initialization, so the two halves must fail in opposite directions, and neither half was pinned. Under lazy_connect the ingest side resolves to ASYNC -- the client is null and no pull happens at build -- and the read pool defaults to min=0, so build() must return and a write must buffer even against a provider that can supply nothing. Getting that wrong is a data-loss shape rather than an inconvenience: a producer that hard-fails at build() instead of buffering drops exactly the rows store-and-forward promised to keep. Without lazy_connect the pools initialize eagerly, so the same provider must fail the build loudly, and the provider's own message has to survive to the caller -- "not signed in yet" is actionable, a transport-shaped wrapper naming the endpoint is not. The deferred read path gets the same treatment: with query_pool_min=0 the first borrowQuery() is where the pull happens, and it must report the failure rather than hand back a client that never authenticated. Both eager cases run against a live server so the failure is unambiguously the credential and not connectivity. The behaviour was already correct; these pin it. The lazy test deliberately asserts no pull count. Whether the async connect thread has attempted one by then is timing, not contract; what is contract is that neither build() nor the write surfaced the failure. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/test/QuestDBBuilderTest.java | 52 +++++++++++++++++++ .../client/test/QuestDBLazyConnectTest.java | 39 ++++++++++++++ 2 files changed, 91 insertions(+) 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 f2b2140ee..e338aa72c 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -127,6 +127,47 @@ public void testConnectTokenProviderSuppliesBothPoolsAndPoolGrowth() throws Exce } } + @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"; @@ -358,6 +399,17 @@ 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 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(); From d780810b6f426119b99a422db8f7c3b5f6a45f3e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 01:10:27 +0100 Subject: [PATCH 174/192] Bound the chunk size where it is read, not in Numbers parseHexLong grew an overflow check to fix three HTTP chunk-framing bugs. The check was right; its location was not. io.questdb.client.std is an exported package, so rejecting a full-width word changed a shipped contract -- ffffffffffffffff read as -1 before and threw after -- to serve one internal caller. The utility had no business making that judgement. It cannot know its digits are a count a remote peer chose; only the caller knows that. And the strictness left it disagreeing with its own neighbours: parseHexInt two methods above still wraps, and so does the server-side io.questdb.std.Numbers of the same name, whose Long256 decoding depends on the wrap. Nothing at a call site would have shown which of the three you were looking at. So parseHexLong goes back to two's-complement accumulation, and AbstractChunkedResponse bounds its own input. The javadoc that promised the rejection now warns about the wrap instead and points at the chunk parser as the worked example. The guard has to run BEFORE the parse. A check on the returned value cannot work: 10000000000000000 wraps to 0, which is indistinguishable from a genuine 0 -- and that residue is the worst of the three, read as the terminal chunk and reporting a truncated body as complete. It counts SIGNIFICANT hex digits, skipping leading zeros, and rejects more than 15. 16^15-1 is about 1.15e18 and always fits a long; a sixteenth digit can push past Long.MAX_VALUE. Counting raw length would reject 00000000000000000001 -- twenty characters, value one -- and break framing against a conformant server that pads, which is a worse failure than the one being prevented. The bound is also strictly stronger than the overflow check it replaces: the smallest thing it turns away is a 2^60-byte chunk, so it rejects absurd-but-representable sizes that merely fitting in a long admits. The three framing tests are unchanged and still pass -- they assert the framing outcome and the "malformed chunk size" message, both produced at the caller either way. Removing the new guard reproduces all three distinct failures: a 30s timeout for the negative residue (the spin), "a terminal chunk (a truncated body reported as complete)" for the zero one, and "a data chunk" for the positive one. NumbersTest now pins the wrap values rather than a rejection, and a new test covers the padded size line, which nothing exercised before. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/client/AbstractChunkedResponse.java | 50 +++++++++++++++---- .../java/io/questdb/client/std/Numbers.java | 44 ++++++++-------- .../http/client/ChunkedResponseTest.java | 36 +++++++++++++ .../questdb/client/test/std/NumbersTest.java | 29 +++++------ 4 files changed, 111 insertions(+), 48 deletions(-) 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 545dbf461..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; @@ -146,17 +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 { - // parseHexLong rejects an overflowing size rather than wrapping it, so nothing - // is needed here beyond catching NumericException below. Each residue used to - // break framing its own way: a negative one (8000000000000000 is the smallest) - // matched neither the "size > 0" data branch nor the "size == 0" terminator - // below, so the state machine looped on it forever; zero (10000000000000000) - // read as the TERMINAL chunk, truncating the response and losing framing for - // the next keep-alive response on the connection; a positive residue framed a - // short data chunk and mis-read everything after it. The size line is chosen by - // the server, which for an OIDC discovery or token response is untrusted. - 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; @@ -267,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/std/Numbers.java b/core/src/main/java/io/questdb/client/std/Numbers.java index c372a60a9..78a3bf4c7 100644 --- a/core/src/main/java/io/questdb/client/std/Numbers.java +++ b/core/src/main/java/io/questdb/client/std/Numbers.java @@ -344,27 +344,26 @@ public static long parseHexLong(CharSequence sequence) throws NumericException { } /** - * Parses a hexadecimal sequence into a NON-NEGATIVE long, rejecting anything above - * {@link Long#MAX_VALUE} rather than wrapping. + * 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. *

    - * This used to accumulate {@code val << 4} unchecked, which silently discarded the high bits of any - * sequence long enough to overflow. That is indefensible wherever the digits are a COUNT chosen by a - * remote peer, and every residue is wrong in its own way: an HTTP chunk size of - * {@code 8000000000000000} wrapped negative and hung the framing state machine, one of - * {@code 10000000000000000} wrapped to zero and read as the terminal chunk (a truncated body reported - * as complete), and longer values wrapped to short positive counts that mis-framed everything after - * them. - *

    - * The cost of the check is that a full-width 16-digit word with the high bit set -- {@code - * ffffffffffffffff}, previously read as {@code -1} -- is now rejected. Nothing in this library parsed - * one; a caller that wants two's-complement wrap-around must do its own accumulation. + * 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, in {@code [0, Long.MAX_VALUE]} - * @throws NumericException if the sequence is empty, holds a non-hex character, or denotes a value - * above {@link Long#MAX_VALUE} + * @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) { @@ -372,15 +371,12 @@ public static long parseHexLong(CharSequence sequence, int lo, int hi) throws Nu } long val = 0; + long r; for (int i = lo; i < hi; i++) { - int digit = hexToDecimal(sequence.charAt(i)); - // Test BEFORE shifting: the shift is what loses the high bits, so afterwards there is nothing - // left to detect. val*16 + digit <= MAX_VALUE <=> val <= (MAX_VALUE - digit) >> 4, and both - // sides stay non-negative, so this cannot itself overflow. - if (val > (Long.MAX_VALUE - digit) >> 4) { - throw NumericException.instance().put("hex value exceeds Long.MAX_VALUE"); - } - val = (val << 4) + digit; + int c = sequence.charAt(i); + long n = val << 4; + r = n + hexToDecimal(c); + val = r; } return val; } 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 c8b6d9116..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 @@ -259,6 +259,42 @@ public void testPositiveWrappingChunkSizeIsRejectedRatherThanMisframing() { 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); diff --git a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java index 9d4e171dd..0467235da 100644 --- a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java +++ b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java @@ -271,28 +271,29 @@ public void testHexInt() { } @Test - public void testParseHexLongRejectsOverflowRatherThanWrapping() { - // the boundary itself must be accepted... + public void testParseHexLongWrapsOnOverflowAndCallersBoundIt() { + // Two's-complement, like parseHexInt beside it and the server-side Numbers of the same name, whose + // Long256 decoding depends on the wrap. io.questdb.client.std is an exported package, so this is a + // shipped contract and not an internal detail. assertEquals(Long.MAX_VALUE, Numbers.parseHexLong("7fffffffffffffff")); assertEquals(0L, Numbers.parseHexLong("0")); assertEquals(0xacL, Numbers.parseHexLong("ac")); - // ...and leading zeros must not be mistaken for magnitude + // leading zeros carry no magnitude assertEquals(1L, Numbers.parseHexLong("000000000000000000001")); // range form assertEquals(0xf0L, Numbers.parseHexLong("xxF0yy", 2, 4)); - // ...while everything past it is rejected rather than wrapped. The three residues matter - // separately: unchecked accumulation turned them into a negative value, a zero (which the HTTP - // chunk parser reads as the terminal chunk, truncating the body) and a short positive count. - assertHexLongRejected("8000000000000000"); // negative residue, and the smallest overflow - assertHexLongRejected("10000000000000000"); // zero residue - assertHexLongRejected("10000000000000001"); // positive residue - assertHexLongRejected(""); + // The wrap itself, in the three shapes that break a length-prefixed format differently. Pinning the + // VALUES rather than a rejection is the point: a caller that parses a count it did not choose has + // to bound the digits before it gets here, because none of these is distinguishable afterwards - + // 10000000000000000 in particular is indistinguishable from a genuine 0. + assertEquals(Long.MIN_VALUE, Numbers.parseHexLong("8000000000000000")); // negative residue + assertEquals(0L, Numbers.parseHexLong("10000000000000000")); // zero residue + assertEquals(1L, Numbers.parseHexLong("10000000000000001")); // positive residue + assertEquals(-1L, Numbers.parseHexLong("ffffffffffffffff")); // the full-width word - // The deliberate cost of the check: a full-width word with the high bit set used to read as -1 and - // is now rejected. Nothing in this library parsed one, and a caller wanting two's-complement - // wrap-around has to accumulate it itself. - assertHexLongRejected("ffffffffffffffff"); + // an empty sequence is still an error rather than a zero + assertHexLongRejected(""); } @Test From 1fc842aaa1e009af57b51f044c01ce677c37dac6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 01:14:35 +0100 Subject: [PATCH 175/192] Document the bound the no-arg recv() now carries recv(int) gained a whole-call deadline and documented it. recv() did not, though it inherits the same bound: both implementations here implement it as recv(defaultTimeout), and that default -- HttpClientConfiguration .getTimeout() -- is positive in every configuration this library builds, since request_timeout is rejected below 1 on the builder and the configuration-string paths alike. So "using the default timeout" quietly changed meaning from per-socket-read to whole-call, on an exported interface that ships a javadoc jar, and said nothing. Three things were missing, only the first of them new: - the bound itself, and what to size it against. Each call starts its own budget, so a large body spread over many calls is unaffected and only a single call that cannot finish in time aborts. Sizing against the whole body would be the natural wrong reading. - the delegation inversion, which is what decides whether the bound exists at all. The recv(int) default defers DOWN to recv() and discards its argument; the implementations here do the reverse. An implementation overriding only recv() is therefore unbounded on both methods, and one extending AbstractResponse or AbstractChunkedResponse is bounded on both. recv(int) documents its half of that; recv() documented none of it. - the @return contract, which disagreed between the pair. recv(int) says "or null once the body has been fully read"; recv() said only "the received fragment", while the one no-arg caller in this library -- AbstractLineHttpSender's construct-time /settings probe -- loops on exactly that null. Pre-existing, fixed while the file is open. Documentation only. The alternative, restoring the legacy shape by passing a non-positive timeout from recv(), was rejected: it would preserve an unwritten contract by removing a real guard from the /settings probe, which reads from a server before any protocol version has been agreed. No new test -- ResponseTest and ChunkedResponseTest each already drive the no-arg path and assert the bound fires, and ExportedApiCompatibilityTest pins the signatures. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/http/client/Response.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 324a80c52..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,27 @@ */ 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(); From 939c7eee006fca2ab279f4ed75b829961a08e73e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 01:26:39 +0100 Subject: [PATCH 176/192] Write the JsonParser contract the lexer now relies on JsonParser was a bare method signature with no javadoc at all, in an exported package that ships a javadoc jar. Nothing told an implementor what any of the three parameters meant, and this branch then changed one of them: getCharSequence now resolves JSON escapes, so a value written "a\\nb" arrives as four characters rather than five raw ones. A parser carried over from an earlier release that unescapes for itself now decodes twice and turns the \n into a newline -- silent corruption on a minor version bump. The branch's own diff shows the compensating code being removed: ClientInteropTest lost a 68-line unescape() helper. The sharper half is not the escaping, though. getCharSequence returns the unescape sink when a value carried a backslash and the assembly sink when it did not, so the IDENTITY of the returned object now varies with the data. Before this branch it was always the same sink. An implementation that compares tag by identity, or caches the reference, works across escape-free input and then fails on the first value containing an escape -- a failure that depends on payload rather than on code. Also written down while here, neither of them new but neither documented: tag is borrowed for the duration of the call only, and it is null for the four structural events and non-null only for EVT_NAME, EVT_VALUE and EVT_ARRAY_VALUE. An implementor who does not know the second one gets an NPE on the first object it parses. Documentation only, and no test: the behaviour is already pinned from several directions -- testStringEscapesAreDecoded across the escape set, testStringEscapesDecodedAcrossSplitParseCalls and testUnicodeEscapeDecodedAcrossSplitParseCalls for the fragment boundaries, and testStringEscapesExoticAndLenient for the surrogate pair, the backspace/form-feed arms, the lenient malformed-escape arms and a lone unpaired surrogate. What was missing was the contract, not the coverage. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/json/JsonParser.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 From da85158b3bab3f8a74d6b9f8b618cde7bc248ce9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 08:22:58 +0100 Subject: [PATCH 177/192] Restore the Windows FDSet leak injection testSelectFdSetFailureLeaksNothing has been asserting nothing on Windows, and said so: "construction succeeded, so this test's injected failure no longer fires and it proved nothing". The guard it was written for was never exercised. The injection assumed a capacity of Integer.MAX_VALUE would overflow FDSet's size computation negative. It does not overflow far enough. FDSet computes ARRAY_OFFSET + 8 * size in int arithmetic, and 8 * Integer.MAX_VALUE is exactly -8, so the size is ARRAY_OFFSET - 8 -- negative only where ARRAY_OFFSET is 0 or 4. On 64-bit Windows fd_set is { u_int fd_count; SOCKET fd_array[]; }, SOCKET is 8 bytes, and arrayOffset() returns offsetof(fd_set, fd_array[0]) = 8. The size lands on exactly 0, and Unsafe.allocateMemory(0) does not fail: it returns a null pointer. Construction completed, the flag stayed false, and the assertion fired. Only a NEGATIVE size throws IllegalArgumentException. 1 << 28 makes 8 * capacity overflow to exactly Integer.MIN_VALUE, so the size is around -2^31 whatever arrayOffset() reports -- checked against 0, 4, 8 and 16. The comment now carries that reasoning so the next person does not reach for Integer.MAX_VALUE again. Added alongside it: an injection that throws from getSelectFacade(). That reaches the other arm of the same guard, where FDSet is already constructed and the guard has to free it as well as everything the base constructor took -- a strictly larger leak surface than FDSet throwing, and the arm nothing covered. getSelectFacade() sits inside the try for exactly this case (a caller-supplied configuration that throws), and it is deterministic, with no arithmetic to rot. Verified as far as this machine allows: the size arithmetic and allocateMemory's behaviour at 0 and at negative sizes are JDK-level and were run here. The tests themselves cannot be: SelectAccessor's natives are built only from core/src/main/c/windows/select.c, so the class does not link off Windows and both tests Assume out. CI is the first place they run. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/HttpClientConstructorLeakTest.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) 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 index 7c17f0543..4670d5077 100644 --- 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 @@ -33,6 +33,7 @@ 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; @@ -141,17 +142,42 @@ public int getResponseBufferSize() { })); } + @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, which a large capacity overflows negative, and - // allocateMemory rejects a negative size. An allocation that simply fails is exactly the shape a - // real one takes under memory pressure. + // 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 Integer.MAX_VALUE; + return 1 << 28; } })); } From 7b3e207fb927cc6c2acaf7018e71fb124bdf221b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:20:02 +0100 Subject: [PATCH 178/192] Stop a carried interrupt skipping client teardown Thread.join(long) throws InterruptedException the instant the calling thread's flag is set, without ever checking whether the joined thread exited. QwpQueryClient.close() joins its I/O thread that way, so a caller that merely ARRIVED interrupted took the "could not join" return and skipped closePool() and webSocketClient.close(). Those are the only frees for sendScratch, the decoder and the batch-buffer pool. The leak is permanent, not deferred. close() CAS'd closedFlag on entry, so every later close() returns at the guard, and QueryClientPool.reapIdle() removed the worker from `all` before calling shutdown(), so the pool's own close() never sees it either. Nothing reports it: lastCloseTimedOut stays false because the timeout branch never ran. PoolHousekeeper.stop() reaches this. It interrupts the housekeeper thread to break a recovery build's credential pull, and that same thread runs queryPool.reapIdle() immediately afterwards with the flag still set - the loop checks `stop` before senderPool.reapIdle() and not again between the two reaps. A long-lived application that opens and closes handles leaks a buffer pool and a socket per affected close. close() now clears the caller's flag for the duration of the teardown and restores it in the finally, the interrupt-neutral shape FileTokenStore load()/save() already use. The timeout branch keeps its meaning: with the flag out of the way 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 "could not join" and still returns. QueryWorker.shutdown() takes the same treatment for its own dispatch-thread join, which a carried flag turned into an immediate throw and left the dispatch thread running alongside client.close(). QwpQueryClientInterruptedCloseLeakTest connects a client to a loopback QWP server, interrupts the calling thread, closes, and asserts under assertMemoryLeak. Reverting both hunks fails it with 401664 bytes leaked under NATIVE_DEFAULT. It also asserts close() hands the cancellation back rather than swallowing it. Co-Authored-By: Claude Opus 5 (1M context) --- .../cutlass/qwp/client/QwpQueryClient.java | 25 +++++ .../io/questdb/client/impl/QueryWorker.java | 15 ++- ...wpQueryClientInterruptedCloseLeakTest.java | 93 +++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientInterruptedCloseLeakTest.java 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 e8f19330f..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 @@ -652,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(); @@ -700,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(); + } } } 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/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(); + } + } +} From 16180b91821f0c883d7a29d27d4f0ecb07e04ccf Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:24:25 +0100 Subject: [PATCH 179/192] Pin the capability-gap counter across recycles The counters-as-fields fix has two halves and only one of them had a test. BackgroundDrainerDurableAckRetryTest covers the rotating-credential counter with testFlappingCredentialEscalatesAcrossMidDrainRecycles; nothing covered capabilityGapAttempts, so reverting that field to a method local left all 14 BackgroundDrainer suites green. Every other capability-gap test drives ONE connectWithDurableAckRetry() call whose ScriptedWireFactory rejects continuously - (port, 2, Integer.MAX_VALUE) throws on every attempt from the second on. The settle budget is therefore spent inside a single call and the field-vs-local distinction never shows. The shape that needs a field is a flapping cluster: 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. The drainer then sweeps forever, never writes the .failed sentinel, never reports DATA_LOSS, and holds the slot lock plus one of max_background_drainers workers for the life of the process. testFlappingCapabilityGapEscalatesAcrossMidDrainRecycles drives that shape directly. Only the attempt counter can escalate it, which is what makes it discriminating: the wall-clock half stays deliberately per-call, and with a single gap per call lastCapabilityGapNanos is still 0 when it is charged, so the episode clock never leaves zero however many recycles run. The reconnect budget is Long.MAX_VALUE so the OR's other arm cannot fire either. Verified by simulating the pre-fix local - resetting capabilityGapAttempts at the top of connectWithDurableAckRetry(): the new test fails with "a flapping capability gap must reach the escalation instead of recycling forever", and the other 31 tests in the class still pass, which is the gap itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../BackgroundDrainerDurableAckRetryTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 5d32b2a4f..442ebf9ed 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 @@ -541,6 +541,59 @@ public void testCapabilityGapDoesNotCountTowardTheRotating401Dwell() throws Exce }); } + @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(() -> { From e03a512f15e90e267e5e41249e94c994412b7e10 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:26:49 +0100 Subject: [PATCH 180/192] Bound the rotating-401 ride-out under alternation The rotating-credential ride-out quarantines an orphan slot only once BOTH its thresholds are spent: six rejections and a wall-clock dwell. The dwell is anchored at the first rejection of the current run, and the capability-gap, role-reject and transport arms all rewind that anchor on purpose, so an unrelated outage cannot satisfy the floor for free. The attempt counter beside it is only ever cleared by real ack progress. That asymmetry is what the gate wants 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 second disjunct is permanently true, and the AND can never be satisfied. connectWithDurableAckRetry() then never returns: no .failed sentinel, no DATA_LOSS report, the slot lock held, and one worker of a fixed-size BackgroundDrainerPool pinned for the life of the process. That is the outcome MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS already exists to prevent, reached by a route the clamp does not cover. MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_PER_EPISODE caps the ride-out on the one quantity nothing rewinds. The attempt counter shares the episode scope the cap needs - only ack progress clears it - so it bounds the ride-out however the rejections are spaced. It sits far above the ordinary threshold (256 vs 6) because it is a backstop, not a policy: the dwell still decides every case that is not pathological, which is why the two arms asserting that an outage does not count toward the dwell keep passing unchanged. testAlternating401AndOutageStillReachesTheEscalation drives strict alternation. Before the cap it does not fail an assertion - it never returns, and dies on the 60s @Test timeout parked in the backoff at BackgroundDrainer.connectWithDurableAckRetry. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/sf/cursor/BackgroundDrainer.java | 35 +++++++++++++- .../BackgroundDrainerDurableAckRetryTest.java | 47 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) 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 c34f452a6..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 @@ -113,6 +113,28 @@ public final class BackgroundDrainer implements Runnable { * 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. @@ -479,9 +501,18 @@ public WebSocketClient connectWithDurableAckRetry() { // 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 < DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS + || dynamicCredentialAuthElapsedNanos < dynamicCredentialAuthDwellNanos) + && dynamicCredentialAuthAttempts < MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_PER_EPISODE; } if (retryDynamicCredentialAuth) { lastErrorMessage = e.getMessage(); 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 442ebf9ed..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 @@ -465,6 +465,53 @@ public void testConnectLoopAppliesTheClampedRotating401Dwell() throws Exception }); } + @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(() -> { From 7554b84858dc866bbd57c1decea71c0868e1b9a0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:33:52 +0100 Subject: [PATCH 181/192] Wipe the JSON lexer's decode buffers too wipeCredentialState() swept the four sinks it knew about and missed the one that sees the token first. JsonLexer assembles every name and value in its own decode sinks before a listener is ever called, so TokenResponseParser's copy is the second copy; wiping the parsers left the originals untouched. Neither JsonLexer.clear() (parse state only) nor close() (frees the native cache without zeroing it) touches those sinks, and both are private with no accessor, so nothing else could reach them. close() hid this. It runs the sweep and then does jsonLexer = Misc.free(jsonLexer), so the field is null by the time testCloseWipesCredentialState's reflective walk looks, and the lexer is unreachable garbage either way. clearCache() is the case that matters: it deliberately keeps the lexer alive so the instance stays usable for a later signIn(), which left the access, id and refresh tokens legible on the heap for the life of the instance - through the very call a caller makes to sign this process out. JsonLexer.wipe() overwrites both decode sinks, and wipeCredentialState() calls it. The call is null-guarded because close() is documented idempotent and frees the field after wiping; a second close() would otherwise NPE, which is how testUseAfterCloseThrowsClearly and two siblings caught the first version of this change. testClearCacheWipesTheLexerDecodeBuffers signs in, asserts the buffers really carry a secret, calls clearCache(), then asserts none of the three tokens survive. It asserts on the SET of secrets rather than one field's position: 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. Without the wipe it fails with "clearCache() left REFRESH-LEXER-WIPE-ME legible in the lexer's decode buffers". Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 13 +++ .../client/cutlass/json/JsonLexer.java | 21 +++++ .../test/cutlass/auth/OidcDeviceAuthTest.java | 86 +++++++++++++++++++ 3 files changed, 120 insertions(+) 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 index 5f372eefa..bb2cb975c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -2185,6 +2185,13 @@ private void wipeCredentialState() { // 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 @@ -2197,6 +2204,12 @@ private void wipeCredentialState() { 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) { 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 6eac116f8..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 @@ -290,6 +290,27 @@ 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); } 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 index 1203d7efc..71444e47a 100644 --- 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 @@ -1139,6 +1139,68 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { }); } + @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(() -> { @@ -4146,6 +4208,30 @@ private static String jsonUnicodeEscape(int 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") From 1cd272c8b5223a0099d14928f2159f98b6f91146 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:35:13 +0100 Subject: [PATCH 182/192] Qualify when a WebSocket build() pulls a token The httpTokenProvider javadoc stated twice, unconditionally, that over WebSocket a token must be obtainable when build() runs because the initial handshake pulls it. That is true only of an EAGER initial connect. Under lazy_connect=true - or initial_connect_retry=async - the ingest side resolves to ASYNC, no pull happens at build time, and a provider failure surfaces through the error inbox instead. The branch the text denied is the one this same branch added a test for: QuestDBLazyConnectTest.testLazyConnectBuildsAndWritesDespiteAFailingTokenProvider builds a lazy_connect handle with a provider that always throws and asserts both that build() returns a usable sender and that a write buffers. Its own comment names the stake - a producer that hard-fails at build() instead of buffering drops the rows store-and-forward promised to keep. Getting this wrong points an operator the wrong way on a headless host: the documented remedy for "a token must be obtainable at build()" is a blocking signIn() first, and on a machine with nobody to authorize the device code that parks the producer for the device-code lifetime rather than letting it start and buffer. Both branches are now stated, and both already have tests: testLazyConnectBuildsAndWritesDespiteAFailingTokenProvider for the async case, QuestDBBuilderTest.testEagerBuildAndBorrowQuerySurfaceAProviderFailure for the eager one. Documentation only - no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/io/questdb/client/Sender.java | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 8e83ec3c7..645d7b254 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2305,16 +2305,20 @@ public LineSenderBuilder httpToken(String token) { * 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 the initial connection handshake runs during - * {@code build()} and queries the provider once for it, then again once per reconnect handshake - so a - * refreshed token is presented each time the link is (re)established; an already-established WebSocket - * is not re-authenticated mid-stream. The two transports differ 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 the token must be obtainable when {@code build()} runs (the - * initial handshake fails fast otherwise), 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). + * 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 @@ -2324,9 +2328,11 @@ public LineSenderBuilder httpToken(String token) { * 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; over WebSocket a token must already be obtainable - * when {@code build()} runs, since the initial handshake pulls it - otherwise that {@code build()} (or, - * over HTTP, the first row) fails. Running on the send/flush and reconnect paths, the provider must + * 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)}. From 76b1b61d0150e30c6c641c2431f8049f416dcf8f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:43:55 +0100 Subject: [PATCH 183/192] Make TokenStoreKey a value type TokenStore's contract says entries are keyed by TokenStoreKey, and its javadoc invites a custom store backed by an OS keychain, a secrets manager or a vault. TokenStoreKey had no equals or hashCode, so that read as an invitation to a Map that never hits. The failure is quiet and delayed. OidcDeviceAuth builds its key once per instance and reuses it, so a Map-backed store looks correct for the life of that instance; a second instance for the same identity, or a restart that rebuilds an equal key, misses. The caller then sees an interactive device-flow prompt on every refresh, with a persisted token sitting in their store the whole time. The bundled FileTokenStore is unaffected only because it keys by hash() for the file name. equals compares hash() rather than the fields one by one, so equality means exactly "the same store entry". The hash already folds every identity field through the constructor's null-vs-empty audience normalisation, so two keys that address one entry - and one file - are now also equal and share one Map slot, which field-by-field comparison would have got wrong. hashCode delegates to the same value, so the two are consistent by construction. TokenStore's javadoc now states both routes: the key is a value type and may be used as a Map key directly, or hash() gives the same identity as a stable opaque name for a store that needs one. testKeyIsUsableAsAMapKey covers equality, hashCode agreement, inequality on a changed identity, null and foreign-type comparison, Map get/replace through a rebuilt key, and the normalised-audience case. Without equals it fails on the first assertion with two distinct identities. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/TokenStore.java | 5 +++ .../client/cutlass/auth/TokenStoreKey.java | 33 ++++++++++++++ .../test/cutlass/auth/FileTokenStoreTest.java | 44 +++++++++++++++++++ 3 files changed, 82 insertions(+) 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 index a9ed9f6d2..631b68a22 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStore.java @@ -35,6 +35,11 @@ *

    * 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 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 index d8498c7c1..b30489e8c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/TokenStoreKey.java @@ -88,6 +88,30 @@ public TokenStoreKey( 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; } @@ -116,6 +140,15 @@ 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; } 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 index 97217fda3..7bf4df1b4 100644 --- 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 @@ -54,7 +54,9 @@ 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; @@ -1000,6 +1002,48 @@ public void testHashMatchesFrozenCrossLanguageContract() throws Exception { }); } + @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(() -> { From 9d5d053b69087b7cc34713c2b2602fd1a6a0d794 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 13:14:50 +0100 Subject: [PATCH 184/192] Pin the OIDC body read's elapsed deadline parseBody bounds the whole body read against an untrusted identity provider, and nothing exercised the bound. getToken() runs once per ILP flush, so losing it stalls ingestion rather than failing it: a provider that keeps delivering, slowly, holds the producer thread indefinitely. Two bounds can end that read and they are not interchangeable. The per-call recv bound catches a read that makes no progress; parseBody's elapsed deadline catches the case every individual read SUCCEEDS while the read as a whole runs past the budget. Only the second one covers a slow-but-progressing peer, and only the first had tests - MockOidcServer.stall() blocks inside recvOrDie, so it dies one frame lower, and dribble() never completes a chunk-size line, so recv never returns a fragment at all. The test drives parseBody directly rather than over a socket. A socket-level reproduction races the two bounds - the elapsed deadline expires while a recv is waiting out an inter-chunk gap, and whichever wins decides the message - so it would pin whichever fired on the day and flake on the other. The Response here hands back an EMPTY fragment immediately and forever: every recv succeeds, so the recv bound cannot fire; totalBytes never grows, so the 4 MiB cap cannot fire; the lexer is fed nothing, so it cannot throw. The elapsed deadline is the only exit left, which is the line under test. Replacing the deadline check with `if (false)` makes it hang until the 30s @Test timeout instead of failing an assertion - the wedge itself. Test only; no production change. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/cutlass/auth/OidcDeviceAuthTest.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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 index 71444e47a..eccf2e0ad 100644 --- 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 @@ -30,6 +30,9 @@ 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; @@ -45,6 +48,8 @@ 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; @@ -1139,6 +1144,73 @@ public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { }); } + @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(() -> { From 7dd93588b24ceca3d3c10ba82fdb58b1e2bf8af7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 15:07:18 +0100 Subject: [PATCH 185/192] Copy printable text in one go, not per character putAsPrintable walks its input a character at a time and appends with put(char). On StringSink that is a capacity check per CHARACTER, where its put(CharSequence) override does one for the whole sequence and then copies in a tight loop. That regressed a real path. The ILP error render used to build its message with put(sink) - the bulk form - and now routes the server's error body through putAsPrintable so a hostile or proxied endpoint cannot splice ANSI or bidi characters into a log line. The escaping is right and stays; what was lost along with it is the bulk copy, over a body the client does not cap, on every failed flush. putAsPrintable now classifies before it copies. It scans for the first display-unsafe code point and, finding none, hands the whole sequence to put(CharSequence); only a sequence that actually carries something unsafe takes the character-by-character escaping loop. A server error body is almost always entirely printable, so the common case is a scan plus one bulk copy instead of N capacity checks and N interface calls. Mixed input pays the extra scan and then takes the same loop as before. That is the rare case and the one where correctness rather than speed is the point. The shape mirrors OidcDeviceAuth.sanitizeForDisplay, which returns its input untouched on the same test. Behaviour is unchanged: the fast path only replaces a per-character copy of text that was already going to be emitted verbatim. Both branches were already covered - the emoji case exercises the fast path, the bidi, lone surrogate and supplementary-format cases the escaping path - and testMessage_putAsPrintableAgreesOnBothPaths adds the guard the split itself creates, pinning that the same text with and without one unsafe code point differs only by that code point's escape. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/questdb/client/std/str/Utf16Sink.java | 27 ++++++++++++++++++- .../cutlass/line/LineSenderExceptionTest.java | 16 +++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 44412e86c..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 @@ -48,7 +48,32 @@ default void putAsPrintable(CharSequence nonPrintable) { // 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. - for (int i = 0, n = nonPrintable.length(); i < n; ) { + // + // 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)) { 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 c6ddcb055..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 @@ -84,6 +84,22 @@ public void testMessage_putAsPrintableKeepsEmoji() { 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); From 7faad666fa493be905bcd6c3c10b65ebb0024014 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 17:31:58 +0100 Subject: [PATCH 186/192] Document the credential exception as the API it is QwpCredentialUnavailableException called itself "an internal marker" while being public in an exported package. One of those had to give, and the code settles it: the type is reachable, so the documentation was the wrong half. It cannot be package-private. It is thrown in io.questdb.client.cutlass.qwp.client and handled in io.questdb.client.cutlass.qwp.client.sf.cursor, and both packages are exported. Moving it somewhere unexported would also buy nothing on the artifact that ships: JDK 8 is the source of truth for this module and cannot compile module-info at all, so exports do not constrain the primary jar. Nor is it unreachable in practice. QwpWebSocketSender.newReconnectFactory() is plain public - not even @TestOnly - so a caller can drive ReconnectFactory.reconnect() itself, run the endpoint walk, and receive this type with nothing in between to unwrap it. What IS true is the narrower statement the javadoc now makes: no path out of build(), flush() or a row call delivers it. The SYNC and OFF foreground connects both catch it and rethrow providerFailure(), so an ordinary caller sees the provider's own exception; the running background drainer catches it and retries under Invariant B. The docs now say where it can be met, why it is public, and what a caller who meets it should do with it - unwrap it, as the two foreground paths do. providerFailure() also loses the word "marker" and gains its null contract: the constructor dereferences its argument to build the message, so any constructed instance carries a non-null failure. Documentation only; no behaviour or signature changes. ExportedApiCompatibility Test, the WebSocket and query-client token-provider suites and the drainer credential-outage suite all pass, and the javadoc build is clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../QwpCredentialUnavailableException.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 index 9f58d2e62..2652a7f79 100644 --- 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 @@ -42,10 +42,22 @@ * fails fast, because a connectivity error is the caller's to see during initialization, * not after the drainer is running. *

    - * This is an internal marker that carries the provider's own exception: it exists so - * the send loop can tell "the provider failed" apart from "the network failed". A - * foreground connect unwraps it via {@link #providerFailure()} and rethrows the - * provider's exception, so {@code build()} surfaces the provider's error directly. + * 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; @@ -59,7 +71,8 @@ public QwpCredentialUnavailableException(RuntimeException providerFailure) { /** * The exception the token provider threw, for a caller that must surface the - * provider's own error instead of this marker. + * 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; From 89f562288be62b551e70e775d671522e23c52847 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 17:50:53 +0100 Subject: [PATCH 187/192] Pin the two uncovered escaping callsites Four production sites escape attacker-influenced text before it reaches a log line or a terminal. Two of them had no test that observed the escape. putAsPrintable(char) had none at all. Its only production caller is ConfStringParser, whose test asserted "invalid character" and the position and stopped there - so the overload could emit the raw character, or nothing, and stay green. It is the overload that renders a control char from a connect-string, and before this branch it escaped only the LOW BYTE, which turns U+202E into a full stop: text that looks ordinary rather than escaped. The parser test now asserts the four-hex escape and the absence of the raw char, and Utf16SinkPrintableTest covers the overload directly - one case per class the classifier rejects, both ends of the printable range, and the four-digit width - plus U+2028, U+2029 and the BOM through a sink, which DisplaySafeTest classifies but nothing rendered. QwpWebSocketSender's table-name check was the fourth callsite. Its three siblings are covered - QwpUdpSender and QwpTableBuffer by QwpUdpSenderTest, the ILP names by AbstractLineSender's tests - and this one could regress to a raw concatenation unnoticed. Verified by reverting both: putAsPrintable(char) to the pre-branch low-byte form fails testPutAsPrintableCharUsesFourHexDigits, testPutAsPrintableCharEscapesEveryUnsafeClass and the strengthened testInvalidCtrlCharsInValue; the table-name message back to a concatenation fails testIllegalTableNameIsEscapedInTheMessage. Tests only; no production change. Co-Authored-By: Claude Opus 5 (1M context) --- .../qwp/client/QwpWebSocketSenderTest.java | 24 ++++ .../test/impl/ConfStringParserTest.java | 7 ++ .../test/std/str/Utf16SinkPrintableTest.java | 105 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/std/str/Utf16SinkPrintableTest.java 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/impl/ConfStringParserTest.java b/core/src/test/java/io/questdb/client/test/impl/ConfStringParserTest.java index e25a2095b..b1f93d257 100644 --- a/core/src/test/java/io/questdb/client/test/impl/ConfStringParserTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/ConfStringParserTest.java @@ -82,6 +82,13 @@ public void testInvalidCtrlCharsInValue() { pos = ConfStringParser.value(config, pos, sink); Assert.assertTrue(pos < 0); TestUtils.assertContains(sink, "invalid character"); + // ...and that the offending char is RENDERED as an escape rather than spliced in raw. This is + // the only production caller of putAsPrintable(char), and asserting the prefix alone let that + // overload emit anything at all: a config string carrying an ESC or a bidi override would then + // rewrite the terminal of whoever read the parse error. + TestUtils.assertContains(sink, String.format("\\u%04x", badChar)); + Assert.assertTrue("the raw control char must not reach the message", + sink.toString().indexOf(badChar) < 0); TestUtils.assertContains(sink, "at position 11"); assertNoNext(config, pos); } diff --git a/core/src/test/java/io/questdb/client/test/std/str/Utf16SinkPrintableTest.java b/core/src/test/java/io/questdb/client/test/std/str/Utf16SinkPrintableTest.java new file mode 100644 index 000000000..61759eabe --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/std/str/Utf16SinkPrintableTest.java @@ -0,0 +1,105 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.std.str; + +import io.questdb.client.std.str.StringSink; +import org.junit.Assert; +import org.junit.Test; + +/** + * Rendering coverage for {@code Utf16Sink.putAsPrintable}, at the sink rather than through an exception + * message. + *

    + * {@code DisplaySafeTest} pins the CLASSIFIER - which code points are safe - and the + * {@code LineSenderException} tests pin the {@code CharSequence} overload through one caller. Two things + * fell between them: the single-char overload, whose only production caller + * ({@code ConfStringParser}) asserts the message prefix and the position but never the escape, and the + * code points that only the sink can show are emitted correctly - U+2028, U+2029 and the BOM, which the + * classifier rejects but which no test followed through a sink. + *

    + * Both matter for the same reason the escaping exists at all: these strings are rendered into a log line + * or a terminal, and an unescaped bidi override or ANSI escape rewrites what a human reads. + */ +public class Utf16SinkPrintableTest { + + @Test + public void testPutAsPrintableCharEscapesEveryUnsafeClass() { + // C0, DEL, C1, bidi override, BOM, and a lone surrogate - one per class the classifier rejects + assertCharRenders((char) 0x00, "\\u0000"); + assertCharRenders((char) 0x1b, "\\u001b"); + assertCharRenders((char) 0x7f, "\\u007f"); + assertCharRenders((char) 0x9f, "\\u009f"); + assertCharRenders((char) 0x202e, "\\u202e"); + assertCharRenders((char) 0xfeff, "\\ufeff"); + assertCharRenders((char) 0xd800, "\\ud800"); + } + + @Test + public void testPutAsPrintableCharKeepsPrintableAscii() { + // the boundaries of the printable range, which an off-by-one on either end would escape + assertCharRenders(' ', " "); + assertCharRenders('A', "A"); + assertCharRenders('~', "~"); + } + + @Test + public void testPutAsPrintableCharUsesFourHexDigits() { + // The escape must name the char, not its low byte. An implementation that truncates renders U+202E + // as . - a full stop - which is worse than useless: it looks like ordinary text. + StringSink sink = new StringSink(); + sink.putAsPrintable((char) 0x202e); + Assert.assertEquals("\\u202e", sink.toString()); + Assert.assertNotEquals("\\u002e", sink.toString()); + } + + @Test + public void testPutAsPrintableSequenceEscapesLineAndParagraphSeparators() { + // U+2028 and U+2029 are neither C0/C1 nor Cf, so they need their own arm in the classifier; through + // a sink they must come out escaped, because a JSON or GUI log consumer treats them as line breaks + // and a tampered field could forge an apparent extra log line. + assertSequenceRenders("a" + (char) 0x2028 + "b", "a\\u2028b"); + assertSequenceRenders("a" + (char) 0x2029 + "b", "a\\u2029b"); + assertSequenceRenders("a" + (char) 0xfeff + "b", "a\\ufeffb"); + } + + private static void assertCharRenders(char c, String expected) { + StringSink sink = new StringSink(); + sink.putAsPrintable(c); + Assert.assertEquals("rendering of char 0x" + Integer.toHexString(c), expected, sink.toString()); + } + + private static void assertSequenceRenders(CharSequence input, String expected) { + StringSink sink = new StringSink(); + sink.putAsPrintable(input); + Assert.assertEquals(expected, sink.toString()); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c > 0x7e) { + Assert.assertTrue("the raw char 0x" + Integer.toHexString(c) + " must not survive: " + + sink, sink.toString().indexOf(c) < 0); + } + } + } +} From 5ecb431f65e0b1a6f125e19c53f3f479809bc3c6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 17:56:36 +0100 Subject: [PATCH 188/192] Pin snapshot-before-validate on all three callsites Three places pull a token from an HttpTokenProvider, validate it, and write it into an Authorization header, and all three snapshot the pulled value first so the bytes that are checked are the bytes that are sent. Only the ILP sender's copy had a test. The rule exists because HttpTokenProvider explicitly invites a provider to return a reused mutable buffer. Without the snapshot, validateToken scans the live sequence and the header write materialises it a second time; a mutation landing between those two reads passes the check and splices CR/LF into the header. Deleting the snapshot at either uncovered callsite left the whole suite green. Both now have the same coverage the ILP sender has: QwpQueryClient.resolveAuthorizationHeader, driven through getAuthorizationHeaderForTest, and Sender.buildWebSocketAuthHeader's supplier, driven through a real upgrade against TestWebSocketServer and asserted on the header the server recorded. HandOffToken moves out of LineHttpSenderTokenProviderTest and becomes test.tools.HandOffCharSequence rather than being copied twice more. It swaps its contents the instant a full scan completes and materialises current state from toString(), which is what a StringSink-backed buffer does - and is what makes the fix observable rather than merely present. The non-vacuity guard is a pull counter, not a "was it scanned" flag: with the snapshot in place toString() runs BEFORE any scan, so charAt is never called and the hand-off never fires. That is the fix working, so asserting on it would have inverted the test - and did, on the first attempt. Verified by reverting each snapshot in turn: each removal fails its own callsite's test and nothing else. Tests only; no production change. Co-Authored-By: Claude Opus 5 (1M context) --- .../line/LineHttpSenderTokenProviderTest.java | 45 +---------- .../QwpQueryClientTokenProviderTest.java | 29 +++++++ .../client/WebSocketTokenProviderTest.java | 33 ++++++++ .../test/tools/HandOffCharSequence.java | 77 +++++++++++++++++++ 4 files changed, 141 insertions(+), 43 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/tools/HandOffCharSequence.java 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 index 5da4e3f27..3b416bf77 100644 --- 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 @@ -31,6 +31,7 @@ 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; @@ -349,7 +350,7 @@ public void testTokenMutatedBetweenValidationAndTheHeaderCannotSplice() throws E .address("127.0.0.1:" + server.port()) .protocolVersion(Sender.PROTOCOL_VERSION_V1) .disableAutoFlush() - .httpTokenProvider(() -> new HandOffToken(clean, spliced)) + .httpTokenProvider(() -> new HandOffCharSequence(clean, spliced)) .build()) { sender.table("t").longColumn("v", 1L).atNow(); sender.flush(); @@ -469,46 +470,4 @@ private static void assertProviderTokenRejected(HttpTokenProvider provider, Stri } } - /** - * A provider buffer that hands off its content the moment a full scan of it completes: the first - * traversal reads {@code clean}, and every read after that reads {@code spliced}. That is the shape of a - * reused zero-allocation buffer refreshed by another thread the instant the validating scan finishes - - * the narrowest version of the window, and the one a reader that validates and then re-reads loses. - */ - private static final class HandOffToken implements CharSequence { - private final String spliced; - private CharSequence current; - private boolean handedOff; - - HandOffToken(String clean, String spliced) { - this.current = clean; - this.spliced = spliced; - } - - @Override - public char charAt(int index) { - final char c = current.charAt(index); - if (!handedOff && index == current.length() - 1) { - handedOff = true; - current = spliced; - } - return c; - } - - @Override - public int length() { - return current.length(); - } - - @Override - public CharSequence subSequence(int start, int end) { - return current.subSequence(start, end); - } - - @Override - public String toString() { - // what a StringBuilder-backed buffer does: materialise whatever it currently holds - return current.toString(); - } - } } 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 index 4c87c18bf..e66e0e5ba 100644 --- 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 @@ -34,6 +34,7 @@ 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; @@ -80,6 +81,34 @@ 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(() -> { 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 index 2e6d0d512..6dbd2bd29 100644 --- 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 @@ -29,6 +29,7 @@ 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; @@ -60,6 +61,38 @@ */ 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(() -> { diff --git a/core/src/test/java/io/questdb/client/test/tools/HandOffCharSequence.java b/core/src/test/java/io/questdb/client/test/tools/HandOffCharSequence.java new file mode 100644 index 000000000..f35fc7a40 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/tools/HandOffCharSequence.java @@ -0,0 +1,77 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.tools; + +/** + * A {@link CharSequence} that swaps its contents the instant a full scan of it completes. + *

    + * Models the hazard {@code HttpTokenProvider} explicitly invites: a provider that returns a reused + * mutable buffer. Any reader that VALIDATES the sequence and then RE-READS it to build the header has a + * window between those two reads, and a mutation landing in it passes the check and ships the mutated + * bytes - a CR/LF among them - into an {@code Authorization} header. Handing off exactly when the first + * scan finishes puts the mutation in that window deterministically, with no threads involved. + *

    + * {@link #toString()} materialises whatever the sequence currently holds, which is what a + * {@code StringSink}- or {@code StringBuilder}-backed buffer does. That is what makes the fix + * observable: a reader that snapshots BEFORE validating gets the clean value, because the snapshot is + * taken before any scan has triggered the hand-off; a reader that validates first and stringifies after + * gets the spliced one. + */ +public final class HandOffCharSequence implements CharSequence { + private final String spliced; + private CharSequence current; + private boolean handedOff; + + public HandOffCharSequence(String clean, String spliced) { + this.current = clean; + this.spliced = spliced; + } + + @Override + public char charAt(int index) { + final char c = current.charAt(index); + if (!handedOff && index == current.length() - 1) { + handedOff = true; + current = spliced; + } + return c; + } + + @Override + public int length() { + return current.length(); + } + + @Override + public CharSequence subSequence(int start, int end) { + return current.subSequence(start, end); + } + + @Override + public String toString() { + // what a StringBuilder-backed buffer does: materialise whatever it currently holds + return current.toString(); + } +} From b8ce60c59feb193032daa13d89b2dba7b4005369 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:14:04 +0100 Subject: [PATCH 189/192] Stop a carried interrupt failing a delegate close PoolHousekeeper.stop() and SenderPool.stopStartupRecoveryDriver() escalate to Thread.interrupt() when their join times out, to break a recovery build's credential pull. The thread they interrupt is the same one that then runs senderPool.reapIdle() and the startup-recovery step's finally -- both of which close a delegate -- so the flag arrives at close() as an ordinary carried flag rather than an exotic one. That is fatal if unhandled. CountDownLatch.await(t, u) tests Thread.interrupted() before it ever consults the latch, so CursorWebSocketSendLoop.close()'s shutdown await returned having waited 0 ms, close() took the failed-stop branch, and the slot was reported with its store-and-forward flock still held -- precisely the outcome the interrupt was added to prevent. That branch re-asserts the flag, so in a reap sweep every remaining delegate failed the same way: the whole sweep retired its slots and QuestDB.close() returned still holding them, and an immediate reopen of the same sf_dir could fail with "sf slot already in use". QwpWebSocketSender.close() now clears the flag for the duration and restores it on the way out -- the interrupt-neutral shape QwpQueryClient.close(), QueryWorker.shutdown() and FileTokenStore's load()/save() already use. The query half of the pool got this treatment when the escalation landed; the ingest half, which reapIdle() reaches first, did not. An interrupt delivered DURING the close still lands on the await and still takes the failed-stop branch, which is correct: that one really does mean "we could not join". PoolHousekeeper's comment claimed every wait on the pull path is interruptible. It is not -- the token POST's connect, send, await and parse run on the native HTTP client, which no interrupt breaks, and DNS resolution is unbounded -- so the comment now says what the escalation does and does not buy. Two tests drove the failed-stop branch by handing their closer a pending interrupt, which this change deliberately neutralises. They now shrink the loop's bounded-await backstop through setShutdownAwaitTimeoutMillis, the seam written for exactly that: same branch, deterministic, and the class runs in 0.8s instead of 20.5s. Verified both ways: without the production change the new regression test fails with "cursor I/O thread did not stop: close() was interrupted while awaiting shutdown". Co-Authored-By: Claude Opus 5 (1M context) --- .../qwp/client/QwpWebSocketSender.java | 382 ++++++++++-------- .../questdb/client/impl/PoolHousekeeper.java | 20 +- .../client/SlotLockReleasedContractTest.java | 66 ++- 3 files changed, 282 insertions(+), 186 deletions(-) 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 eefdcf869..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 @@ -1277,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; + } + Runnable closeCallback = () -> closeRemainingResources(null); + if (cursorSendLoop != null && cursorSendLoop.delegateClose(closeCallback)) { + rethrowTerminal(terminalError); + return; } - rethrowTerminal(terminalError); + // 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 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 91ac313aa..836611159 100644 --- a/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java +++ b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java @@ -82,10 +82,22 @@ void stop() { // -- 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. Every wait on that path is 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. + // 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); } 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) { From 929b626a4103173acd4597aa048ffa31d41d62c7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:16:44 +0100 Subject: [PATCH 190/192] Stop retrying a response head that cannot change An unparseable response head reached flush0's transport arm and was retried like a network failure. HttpHeaderParser rejects a header block past its fixed 4096-byte buffer (an intermediary stacking Set-Cookie and CSP), a malformed Content-Length, or a status line that is not HTTP/1.x. Retrying it is wrong on both counts. The parser only ever runs on bytes that ARRIVED, so an HttpException is positive evidence the server answered -- the same evidence the 2xx drain arm forty lines above treats as decisive when it refuses to let a drain failure re-send a committed batch. And the head is chosen by an intermediary rather than by chance, so the next attempt parses the same block and fails identically: the loop never converges, it just runs to the budget. Measured against a mock returning a 5000-byte head: 16 sends over 10.8s per flush at the default retry budget, where the same trigger sent once before HttpException reached the arm. For a table without DEDUP keys that is fifteen extra copies of every row, and the flush blocks for eleven seconds before reporting "Connection Failed: header is too large" -- a transport failure that never happened. The catch stays, because what it added was right: without it the HttpException escaped flush0 entirely, taking the client.disconnect() that keeps the next flush off a connection holding a half-read response, and leaving flush() throwing a raw HttpException past every caller's catch for the LineSenderException the contract promises. It moves to its own arm that disconnects, reports a non-retryable LineSenderException naming the malformed head, and does not re-send. lastFlushFailed suppresses the close-time re-flush for the same reason: the server already has the rows. The existing test asserted the retry, so it was pinning this rather than guarding against it. It now proves the flush fails once, names the head rather than a transport error, reports isRetryable() false, and leaves the request count at exactly one against a budget a re-send would spend. Co-Authored-By: Claude Opus 5 (1M context) --- .../line/http/AbstractLineHttpSender.java | 43 ++++++++++++++----- .../line/LineHttpSenderErrorResponseTest.java | 32 ++++++++++---- 2 files changed, 56 insertions(+), 19 deletions(-) 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 82b50f110..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 @@ -790,18 +790,39 @@ private void flush0(boolean closing) { continue; } throwOnHttpErrorResponse(statusCode, response, false, actualTimeoutMillis); - } catch (HttpClientException | HttpException e) { - // this is a network error, we can retry. + } 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. // - // HttpException too: response.await() above hands the response head to HttpHeaderParser, - // which rejects one it cannot parse - a header block past its fixed 4096-byte buffer (an - // intermediary stacking Set-Cookie/CSP), a malformed Content-Length, a status line that is - // not HTTP/1.x - by throwing HttpException. That is a SIBLING of HttpClientException, not a - // subclass, so it escaped this catch and with it the retry, the address rotation and the - // client.disconnect() that keeps the next flush off a connection holding a half-read - // response. It also left flush() throwing a raw HttpException rather than the - // LineSenderException its contract promises, past every caller's catch. An unparseable head - // is the response being unusable, which is exactly what this arm already handles. + // 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. lastFlushFailed = true; client.disconnect(); // forces reconnect long nowNanos = System.nanoTime(); 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 index 0d58769b0..a45a27c4f 100644 --- 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 @@ -58,15 +58,22 @@ public class LineHttpSenderErrorResponseTest { private static final char RLO = 0x202e; @Test(timeout = 30_000) - public void testMalformedResponseHeadOnFlushIsRetriedAsATransportError() throws Exception { + 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's retry arm and with it the retry, the address rotation and 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. + // 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'); @@ -78,18 +85,27 @@ public void testMalformedResponseHeadOnFlushIsRetriedAsATransportError() throws .address("127.0.0.1:" + server.port()) .protocolVersion(Sender.PROTOCOL_VERSION_V1) // only the flush hits the mock .httpTimeoutMillis(1_000) - .retryTimeoutMillis(100) // exhaust the retry budget quickly + .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, reached through the retry arm; a raw HttpException here is - // the regression - Assert.assertTrue(e.getMessage(), e.getMessage().contains("Connection Failed")); + // 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); } } }); From 5a6c4317be677f0f5f97ece3dae2b28bca5d2c3d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:20:41 +0100 Subject: [PATCH 191/192] Stop a recovered store read undoing a sign-in maybeLoadFromStore() deliberately leaves storeLoadAttempted UNSET when a read THROWS, so a transient fault is retried instead of disabling persistence for the life of the instance. That part is right. What it missed is that the same method runs at the top of getToken(), AHEAD of the cache check, and that adopt() assigns the served kind, the expiry and the ttl unconditionally -- it never compares the file against what is already in memory. So a store that is unavailable across signIn() and readable afterwards undid it. An unmounted home directory, or a container started before its volume attaches, fails the read and the save alike -- one root cause, both through ensureDirectory. The read failure leaves the latch unset, the human authenticates, the save failure is swallowed with a WARN, and then the directory recovers. The next getToken(), which an ILP sender calls once per flush, re-reads the store and installs the PREVIOUS entry over the grant a human just authorized. The failed save is what makes it stick: nothing had rewritten the entry to match memory. persistIfRotated() now latches the flag. Once this instance has produced tokens of its own the on-disk entry is no longer authoritative for it, whether or not the save that follows succeeds -- which is why the latch sits above the rotation check rather than beside the write. Putting it there also covers the refresh-only path through adoptRotatedRefreshToken() with the same line. A store that never yielded a token is unaffected: persistIfRotated() is not reached, so the existing "a failed read must leave the store re-readable" case still re-reads and still serves the persisted token. Verified both ways: without the change the new test reports expected: but was:. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 12 +++++ .../auth/OidcDeviceAuthPersistenceTest.java | 47 +++++++++++++++++++ 2 files changed, 59 insertions(+) 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 index bb2cb975c..4ba9145e7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1653,6 +1653,18 @@ 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; // 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 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 index e84b19cea..a4a43c3b3 100644 --- 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 @@ -1519,6 +1519,53 @@ public void testRepeatedStoreLoadFailureIsThrottledNotRetriedOnEveryCall() throw }); } + @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(() -> { From 0a662f8516d79b2a07c58d29e9547e7a64554ea5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:23:46 +0100 Subject: [PATCH 192/192] Stop writing a store entry adopt() refuses to read 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 directory drops in a file whose fingerprint fields are all derivable from public config, and the next silent refresh presents THEIR refresh token. The rejection is right, but its justification rested on a callsite count -- "persistIfRotated runs solely at the tail of storeTokens, so every entry we write carries at least one token kind" -- and that count was wrong. persistIfRotated() has a second caller. Under groupsInToken the served kind is the id token, so a stored entry carrying only an access token takes adopt()'s own 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 the snapshot it writes is exactly the shape adopt() refuses. The client had written a file it would never read back: every restart re-runs the interactive device flow over a live refresh token sitting on disk, which for a headless getToken() consumer is a hard failure rather than a degraded one. persistIfRotated() now declines that shape instead. Skipping the write leaves the previous entry in place, which is the better of the two outcomes: 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. Weakening the rejection was the alternative and is the wrong direction; it is the guard that stops a store-directory writer swapping in an identity. adopt()'s comment now says why the shape cannot arrive from this client rather than asserting a callsite count that a later caller can silently falsify. Verified both ways: without the guard the new test finds the both-null entry on the store after the rotated refresh. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/cutlass/auth/OidcDeviceAuth.java | 24 +++++++-- .../auth/OidcDeviceAuthPersistenceTest.java | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) 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 index 4ba9145e7..6cac5eeb6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -1422,10 +1422,11 @@ private boolean adopt(PersistedToken token) { // 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", the refresh path only behind a non-blank - // served kind, and persistIfRotated runs solely at the tail of storeTokens - so every entry we - // write carries at least one token kind. A file with a refresh token and nothing else came - // from somewhere else. + // "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 @@ -1665,6 +1666,21 @@ private void persistIfRotated() { // 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 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 index a4a43c3b3..4191b83fa 100644 --- 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 @@ -1519,6 +1519,58 @@ public void testRepeatedStoreLoadFailureIsThrottledNotRetriedOnEveryCall() throw }); } + @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(() -> {