From 86d65ffbb00068cd0b15d2cfde32775b51555b90 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 17 Jun 2026 16:58:44 +0100 Subject: [PATCH 01/57] 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 ab127c6e..3c8a6bd0 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 00000000..3e540320 --- /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 8e9513b1..dc229766 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 00000000..5235fa1d --- /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 00000000..184d0982 --- /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 00000000..d10d7001 --- /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 00000000..ae4acefa --- /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 166a7a28..2a099266 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 565a0234..528deb0e 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 398aa70a..3d028212 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 ed3c35c6..368722f9 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 00000000..61443901 --- /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 00000000..f3f82e57 --- /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 2e8cd96e..2c67bb81 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 00000000..5e6adece --- /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 02/57] 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 dc229766..eeac0820 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 d10d7001..82681cd2 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 ae4acefa..a8f0a6e1 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 3d028212..57b76630 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 f3f82e57..bf360f5a 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 00000000..092f8fef --- /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 03/57] 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 a8f0a6e1..802b423b 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 bf360f5a..ab0b28b5 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 04/57] 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 92dba65d..16c7b559 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 05/57] 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 57b76630..f59d6044 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 ab0b28b5..8198dd2e 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 06/57] 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 802b423b..2c77a045 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 8198dd2e..2badf535 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 07/57] 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 82681cd2..fa1314d4 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 2c77a045..38b38fc1 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 2badf535..37561fa1 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 08/57] 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 38b38fc1..701117d7 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 37561fa1..07806d74 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 09/57] 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 f59d6044..35841d2d 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 092f8fef..8a0725da 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 10/57] 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 61443901..41541ece 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 07806d74..8b4d9947 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 11/57] 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 701117d7..692a35d5 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 528deb0e..b2ba8d5e 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 368722f9..3cd47996 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 8b4d9947..6c53f639 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 2c67bb81..9e781b71 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 12/57] 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 3c8a6bd0..3252102f 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 692a35d5..381462d2 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 41541ece..37139d6b 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 6c53f639..6c685cce 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 13/57] 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 381462d2..160b6229 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 6c685cce..2b1d2ed1 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 14/57] 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 160b6229..655fc88a 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 2b1d2ed1..4df859d3 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 15/57] 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 fa1314d4..b0a6467e 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 655fc88a..b91d97b4 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 4df859d3..ae20f412 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 16/57] 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 b91d97b4..8d0aab6c 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 17/57] 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 8d0aab6c..b798572c 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 b2ba8d5e..3f28b5b0 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 18/57] 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 37139d6b..9d928be6 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 ae20f412..edf35a18 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 19/57] 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 b798572c..386674b8 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 edf35a18..4a56e7bb 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 20/57] 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 386674b8..89ec7a50 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 4a56e7bb..14457e8c 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 21/57] 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 89ec7a50..f0dc3cb1 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 9d928be6..40f532ce 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 14457e8c..6f94479a 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 22/57] 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 35841d2d..f3092d47 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 00000000..2b08fa30 --- /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 23/57] 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 6f94479a..56d5a3d6 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 24/57] 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 56d5a3d6..132b2cb9 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 25/57] 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 f0dc3cb1..b91cee50 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 132b2cb9..ec15e6ce 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 26/57] 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 ee999559..f82c1262 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 2a099266..c3a337e1 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 6bd20a94..98b9f7af 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 27/57] 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 f53e1ae5..3e07e250 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 ec15e6ce..ab5ce73d 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 9e781b71..74d8d9d5 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 2b08fa30..c3256cff 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 28/57] 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 3e540320..9a23f892 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 eeac0820..26f0c8a5 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 5235fa1d..f398ebfd 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 184d0982..c08eebd1 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 b0a6467e..1ccd6dbe 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 b91cee50..f85bebe7 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 f82c1262..9e5543c5 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 c3a337e1..02c30505 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 3f28b5b0..cd2e75c0 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 f3092d47..82519f40 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 3e07e250..4346ce9e 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 29/57] 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 3252102f..880eb897 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 00000000..41c91234 --- /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 c08eebd1..2f4447d3 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 f85bebe7..2bd0087f 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 00000000..8604e1cb --- /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 ab5ce73d..c241de94 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 5e6adece..5c698b50 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 30/57] 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 880eb897..b61e5a10 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 41c91234..da2d72cc 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 2f4447d3..8389c43d 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 2bd0087f..8d99c3aa 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 8604e1cb..f2c82e65 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 c241de94..5af6a396 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 5c698b50..2ca102b9 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 31/57] 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 b61e5a10..02d1338c 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 8d99c3aa..7fa96d91 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 5af6a396..108b1787 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 32/57] 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 02d1338c..951ab6a9 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 7fa96d91..840e3b61 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 108b1787..34ef78a7 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 33/57] 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 840e3b61..c0ae2c93 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 34ef78a7..3c7f86b4 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 34/57] 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 c0ae2c93..998aabbe 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 3c7f86b4..0a5cafc7 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 35/57] 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 998aabbe..0392d917 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 0a5cafc7..2f0fffe4 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 36/57] 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 0392d917..bec2cbab 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 2f0fffe4..968b9162 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 37/57] 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 9a23f892..c58ee98a 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 26f0c8a5..c6dc2284 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 e34a1923..979bfe55 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 3cd47996..fb7b844d 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 00000000..8e81076e --- /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 4db34d44..db1a59e5 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 38/57] 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 bec2cbab..2e0f7cad 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 968b9162..a1a0044d 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 39/57] 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 b3b521da..b234d2a2 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 4346ce9e..3e790903 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 6c9901db..9870c645 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 4d05487e..c6ddcb05 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 40/57] 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 2e0f7cad..5df5855a 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 41/57] 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 5df5855a..49f280f5 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 42/57] 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 49f280f5..9c4c0806 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 a1a0044d..10832e13 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 43/57] 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 c6dc2284..344151f4 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 9c4c0806..5d1b6968 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 cd2e75c0..0ca209ba 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 3e790903..e2cb39b0 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 10832e13..71dfda22 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 44/57] 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 c58ee98a..de221c68 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 344151f4..d0b0ccb9 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 82519f40..ab41e73a 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 00000000..bda404e1 --- /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 8a0725da..66e0419f 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 45/57] 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 d0b0ccb9..2792b1d4 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 46/57] 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 5d1b6968..69ad9a31 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 0ca209ba..0c4b0c4c 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 71dfda22..d8d3863f 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 47/57] 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 ab41e73a..835fa209 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 c3256cff..4cdf665c 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 48/57] 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 1ccd6dbe..92d0f1df 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 00000000..71895f4e --- /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 e2cb39b0..a706ee49 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 f2c82e65..21d80da9 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 d8d3863f..c12ec6a2 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 98b9f7af..2a80a958 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 9870c645..2867c27d 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 66e0419f..de05bd26 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 8e81076e..cb8b8ef4 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 49/57] 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 69ad9a31..9f528503 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 c12ec6a2..37cda13c 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 50/57] 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 9f528503..46e7f3ca 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 71895f4e..2577d165 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 a706ee49..e824c692 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 51/57] 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 835fa209..3f9da72e 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 fb7b844d..93ac401c 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 37cda13c..5a1bb131 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 4cdf665c..c8ea1c56 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 00000000..c65b6d49 --- /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 52/57] 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 46e7f3ca..d9add87e 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 5a1bb131..db27ef29 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 53/57] 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 db27ef29..0c70a23d 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 54/57] 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 de221c68..6ac1bd09 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 a60f05da..dfdfc68e 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 d9add87e..6e76366a 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 3f9da72e..188ba9ec 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 1706401e..f5f6d9a4 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 0c70a23d..1973bd72 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 de05bd26..9a07c749 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 d4ad155e..1d3c92be 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 00000000..96d89991 --- /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 00000000..e8ea79e0 --- /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 2ca102b9..a1cfefb4 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 55/57] 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 951ab6a9..2e03f8cd 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 6e76366a..476acde0 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 1973bd72..56854c30 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 96d89991..b542f914 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 56/57] 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 476acde0..4a452858 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 57/57] 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 4a452858..1a3a8a9b 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 f5f6d9a4..fac498e7 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 56854c30..d01c45cd 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 b542f914..be3ea8bf 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")); } } }