From 86d65ffbb00068cd0b15d2cfde32775b51555b90 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 17 Jun 2026 16:58:44 +0100 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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(() -> {