diff --git a/README.md b/README.md index ab127c6e..2e03f8cd 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,80 @@ 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, and opens the URL in your default browser when one is available; authorize there (or open the URL on any device, such as your phone), enter the code, and the token is cached in memory and refreshed silently on later calls. + +```java +import io.questdb.client.Sender; +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.signIn(); // sign in once: prompts on first use, then caches and refreshes + + // Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each + // request, so a long-lived sender keeps working as the token rotates. getToken() refreshes + // silently and never prompts on the flush path. + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("questdb.example.com:9000") + .enableTls() + .httpTokenProvider(auth::getToken) + .build()) { + sender.table("trades") + .symbol("symbol", "ETH-USD") + .doubleColumn("price", 2615.54) + .atNow(); + } +} +``` + +Prefer `httpTokenProvider(auth::getToken)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history. + +By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`: + +```java +// print only, do not open a browser: +try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "https://questdb.example.com:9000", + new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) { + auth.signIn(); +} +``` + +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(...)` reads the OIDC client id, scope, audience and endpoints from the server's `/settings`, and the identity provider's client must have the device authorization grant enabled. When the server does not advertise its device authorization endpoint (today's servers), pin the identity provider by its issuer so the client can discover the endpoint from the issuer's `.well-known/openid-configuration` document: + +```java +try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "https://questdb.example.com:9000", + new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) { + auth.signIn(); +} +``` + +The identity provider's device authorization and token endpoints must use `https` — a loopback endpoint (`localhost` or `127.0.0.0/8`) may use `http`, since the request never leaves the host — so the device code and refresh token are never sent in cleartext. `allowInsecureTransport(true)` relaxes only the QuestDB `/settings` link (for local development against an `http` QuestDB server), e.g. `OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true))`; it never relaxes the identity provider endpoints, matching the Python client. + +`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin (and, when the issuer has a path, an endpoint advertised by `/settings` must also be under that path — so a tampered `/settings` cannot redirect to a different tenant on a path-based provider such as Keycloak `…/realms/{realm}`), and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered `/settings` cannot move it. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` (optionally with `.issuer(...)`) instead of discovering it. + ### 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..6ac1bd09 --- /dev/null +++ b/core/src/main/java/io/questdb/client/HttpTokenProvider.java @@ -0,0 +1,80 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.std.Chars; + +/** + * Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a + * freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken} - keeps a long-lived sender + * authenticated as the token rotates, without rebuilding it. Over HTTP the sender calls + * {@link #getToken()} as it builds each request; over WebSocket it calls it once per connection + * handshake, on the initial connect and again on every reconnect. + *

+ * {@link #getToken()} runs on the sender's flush and reconnect paths: it must return promptly and must + * not block on interactive input. A quick silent token refresh is fine, but it must not start an + * interactive sign-in. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the + * connection attempt (WebSocket). + * + * @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider) + */ +@FunctionalInterface +public interface HttpTokenProvider { + /** + * Validates a token returned by {@link #getToken()} before the sender writes it into an + * {@code Authorization: Bearer} header. Rejects a null, empty or blank token, and any token + * carrying a control or non-ASCII character (outside {@code 0x20}-{@code 0x7e}): a real bearer + * token is printable ASCII, so a stray CR/LF (which would inject into the HTTP request line) or a + * non-ASCII byte (silently truncated to one byte by the ASCII header writer, yielding a corrupt + * credential the server only answers with 401) is refused rather than sent. The token itself is + * never placed in the exception message - it is the secret this guards. + * + * @param token the token returned by a provider + * @throws LineSenderException if the token is null, empty, blank, or carries a control or + * non-ASCII character + */ + static void validateToken(CharSequence token) { + if (Chars.isBlank(token)) { + throw new LineSenderException("token provider returned a null or empty token"); + } + for (int i = 0, n = token.length(); i < n; i++) { + char c = token.charAt(i); + if (c < 0x20 || c > 0x7e) { + throw new LineSenderException("token provider returned a token containing a control or non-ASCII character; refusing to send it as a credential"); + } + } + } + + /** + * Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the sender + * adds it). Must not return null or empty, and must contain only printable ASCII (no control or + * non-ASCII characters) - the sender splices the value verbatim into an {@code Authorization: + * Bearer} header and rejects a token that violates this (see {@link #validateToken(CharSequence)}). + * + * @return the current HTTP authentication token + */ + 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 604f45d5..dfdfc68e 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -69,6 +69,7 @@ import java.util.Base64; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; /** * Influx Line Protocol client to feed data to a remote QuestDB instance. @@ -1045,6 +1046,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 @@ -1376,7 +1378,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) { @@ -1390,7 +1392,7 @@ public Sender build() { ? DEFAULT_WS_AUTO_FLUSH_INTERVAL_NANOS : TimeUnit.MILLISECONDS.toNanos(autoFlushIntervalMillis); - String wsAuthHeader = buildWebSocketAuthHeader(); + Supplier wsAuthHeader = buildWebSocketAuthHeader(); ClientTlsConfiguration wsTlsConfig = null; if (tlsEnabled) { @@ -2003,6 +2005,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"); } @@ -2010,6 +2015,47 @@ public LineSenderBuilder httpToken(String token) { return this; } + /** + * Supplies the HTTP authentication token from a provider queried as the sender builds each request, + * instead of a fixed {@link #httpToken(String) token} captured once, so a long-lived sender follows + * token refreshes - e.g. an OIDC device-flow token: {@code .httpTokenProvider(auth::getToken)}. + *
+ * Over HTTP the provider is not called at build time: the first call happens when the first row is + * started, then once per flush. Over WebSocket the initial connection handshake runs during + * {@code build()} and queries the provider once for it, then again once per reconnect handshake - so a + * refreshed token is presented each time the link is (re)established; an already-established WebSocket + * is not re-authenticated mid-stream. The two transports differ on a sustained token outage: over HTTP + * a failed pull is retried on the next row, but over WebSocket a pull that keeps failing past the + * reconnect budget terminates the sender for good, like any persistent reconnect failure. A + * lazily-signing-in provider can therefore be wired before the interactive sign-in completes over HTTP, + * where the first pull is deferred to the first row; over WebSocket a token must already be obtainable + * when {@code build()} runs, since the initial handshake pulls it - otherwise that {@code build()} (or, + * over HTTP, the first row) fails. Running on the send/flush and reconnect paths, the provider must + * return promptly and must not block on interactive input (see {@link HttpTokenProvider}). Supported + * over HTTP and WebSocket transport, and mutually exclusive with {@link #httpToken(String)} and + * {@link #httpUsernamePassword(String, String)}. + * + * @param httpTokenProvider supplies the current HTTP authentication token + * @return this instance for method chaining + */ + 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. *
@@ -2035,6 +2081,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; @@ -2867,13 +2916,27 @@ private void appendAddress(String host, int port) { ports.add(port); } - private String buildWebSocketAuthHeader() { + private Supplier buildWebSocketAuthHeader() { if (username != null && password != null) { String credentials = username + ":" + password; - return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + String header = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return () -> header; } if (httpToken != null) { - return "Bearer " + httpToken; + String header = "Bearer " + httpToken; + return () -> header; + } + if (httpTokenProvider != null) { + // pull a fresh token at each (re)handshake so a long-lived WebSocket follows token + // refreshes; validateToken rejects a null/empty/blank return, or a token carrying a + // control or non-ASCII char (both forbidden by the HttpTokenProvider contract), rather + // than send a malformed or CR/LF-injected "Bearer " header + final HttpTokenProvider provider = httpTokenProvider; + return () -> { + CharSequence token = provider.getToken(); + HttpTokenProvider.validateToken(token); + return "Bearer " + token; + }; } return null; } @@ -3799,6 +3862,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"); } @@ -3824,6 +3890,9 @@ private void validateParameters() { if (httpToken != null) { throw new LineSenderException("HTTP token authentication is not supported for UDP transport"); } + if (httpTokenProvider != null) { + throw new LineSenderException("HTTP token provider authentication is not supported for UDP transport"); + } if (username != null || password != null) { throw new LineSenderException("username/password authentication is not supported for UDP transport"); } diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java new file mode 100644 index 00000000..da2d72cc --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/BrowserLauncher.java @@ -0,0 +1,93 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.auth; + +import java.awt.Desktop; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Opens a verification URL in the local default browser, best-effort. Kept separate from + * {@link DeviceCodePrompt} so a runtime without the {@code java.desktop} module fails only when + * {@link DeviceCodePrompt#openBrowser()} is actually used, not when the interface loads. + */ +final class BrowserLauncher { + + // System property to disable the automatic browser launch (default: enabled). Set to "false" on a + // host that must never pop a browser - a server, automation, CI - or to keep a test run headless. + private static final String OPEN_BROWSER_PROPERTY = "questdb.client.oidc.open.browser"; + + private BrowserLauncher() { + } + + /** + * Opens {@code url} in the default browser when it is an http(s) URL and a desktop browser is + * available. Does nothing on a headless JVM, for a non-http(s) URL, on a launch failure, or when the + * {@code questdb.client.oidc.open.browser} system property is set to {@code false}. May throw a + * {@link LinkageError} when the {@code java.desktop} module is absent from the runtime; the caller + * treats that as "no browser available". + */ + static void open(String url) { + if (!Boolean.parseBoolean(System.getProperty(OPEN_BROWSER_PROPERTY, "true"))) { + return; + } + URI uri = safeHttpUri(url); + if (uri == null) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop desktop = Desktop.getDesktop(); + if (desktop.isSupported(Desktop.Action.BROWSE)) { + desktop.browse(uri); + } + } + } catch (Exception ignore) { + // a headless display, a missing default browser or a security restriction must never + // break sign-in: the verification URL and code are already shown to the user + } + } + + /** + * Returns {@code url} as a {@link URI} only when it parses and uses an http(s) scheme, else + * {@code null}. The verification URL is an untrusted identity-provider response field; the + * allowlist stops a javascript:, data: or file: scheme from reaching the OS browser handler. + */ + static URI safeHttpUri(String url) { + if (url == null) { + return null; + } + try { + URI uri = new URI(url); + String scheme = uri.getScheme(); + if (scheme != null && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + return uri; + } + return null; + } catch (URISyntaxException e) { + return null; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java new file mode 100644 index 00000000..f398ebfd --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java @@ -0,0 +1,90 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.auth; + +/** + * The user-facing part of an RFC 8628 device authorization response: the code to type and the URL + * to type it at. A {@link DeviceCodePrompt} receives this object and shows it to the user. + *

+ * The {@code device_code} secret is deliberately not exposed here; it stays inside + * {@link OidcDeviceAuth} and is never shown to the user. + */ +public class DeviceAuthorizationChallenge { + private final int expiresInSeconds; + private final int intervalSeconds; + private final String userCode; + private final String verificationUri; + private final String verificationUriComplete; + + public DeviceAuthorizationChallenge( + String userCode, + String verificationUri, + String verificationUriComplete, + int expiresInSeconds, + int intervalSeconds + ) { + this.userCode = userCode; + this.verificationUri = verificationUri; + this.verificationUriComplete = verificationUriComplete; + this.expiresInSeconds = expiresInSeconds; + this.intervalSeconds = intervalSeconds; + } + + /** + * @return seconds the {@link #getUserCode() user code} stays valid. + */ + public int getExpiresInSeconds() { + return expiresInSeconds; + } + + /** + * @return minimum seconds the client must wait between polls. + */ + public int getIntervalSeconds() { + return intervalSeconds; + } + + /** + * @return the code the user enters at the {@link #getVerificationUri() verification URL}. + */ + public String getUserCode() { + return userCode; + } + + /** + * @return the URL the user opens to authorize the device. + */ + public String getVerificationUri() { + return verificationUri; + } + + /** + * @return a URL with the user code already embedded, so the user need not type it, or + * {@code null} when the identity provider does not supply one. + */ + public String getVerificationUriComplete() { + return verificationUriComplete; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.java new file mode 100644 index 00000000..8389c43d --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/DeviceCodePrompt.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.cutlass.auth; + +import io.questdb.client.std.str.StringSink; + +/** + * Shows an RFC 8628 device authorization challenge to the user, who then opens the verification URL + * in any browser (same machine or phone) and enters the code. {@link OidcDeviceAuth} calls this once + * per interactive sign-in, just before polling the token endpoint. + *

+ * The default is {@link #openBrowser()}: it prints instructions to {@code System.out} and also tries + * to open the verification URL in the local default browser when one is available. Use + * {@link #SYSTEM_OUT} to print only, or supply your own to render the challenge elsewhere, e.g. a + * clickable link or a QR code in a notebook. + */ +@FunctionalInterface +public interface DeviceCodePrompt { + + /** + * Prints the sign-in instructions to {@code System.out} as plain ASCII, without opening a browser. + * The default prompt is {@link #openBrowser()}; use this to opt out of the browser launch. + */ + DeviceCodePrompt SYSTEM_OUT = challenge -> { + String newLine = System.lineSeparator(); + StringSink sb = new StringSink(); + sb.put(newLine); + sb.put("=== QuestDB OIDC sign-in ===").put(newLine); + sb.put("To sign in, open this URL in a browser:").put(newLine); + sb.put(" ").put(challenge.getVerificationUri()).put(newLine); + sb.put("and enter the code: ").put(challenge.getUserCode()).put(newLine); + if (challenge.getVerificationUriComplete() != null) { + sb.put("(or open this URL, the code is already filled in:").put(newLine); + sb.put(" ").put(challenge.getVerificationUriComplete()).put(')').put(newLine); + } + sb.put("Waiting for authorization, up to ").put(challenge.getExpiresInSeconds()).put(" seconds..."); + System.out.println(sb); + }; + + /** + * Returns a prompt that prints the challenge like {@link #SYSTEM_OUT} and then also tries to open + * the verification URL in the local default browser. The browser open is best-effort: it is + * skipped on a headless JVM, on a runtime without the {@code java.desktop} module, or for a + * non-http(s) URL, and never prevents sign-in. Intended for a local terminal; on a remote or + * headless host the printed URL and code remain the way in. This is the default prompt when none + * is configured. + * + * @return a prompt that prints the challenge and opens the verification URL in a browser + */ + static DeviceCodePrompt openBrowser() { + return openBrowser(SYSTEM_OUT); + } + + /** + * Like {@link #openBrowser()}, but renders the challenge with {@code delegate} before opening the + * browser, instead of the built-in {@code System.out} printer. + * + * @param delegate the prompt that shows the challenge to the user + * @return a prompt that runs {@code delegate} and then opens the verification URL in a browser + */ + static DeviceCodePrompt openBrowser(DeviceCodePrompt delegate) { + return challenge -> { + delegate.promptUser(challenge); + String url = challenge.getVerificationUriComplete() != null + ? challenge.getVerificationUriComplete() + : challenge.getVerificationUri(); + try { + BrowserLauncher.open(url); + } catch (LinkageError ignore) { + // the java.desktop module is absent from this runtime; the printed URL and code remain + } + }; + } + + /** + * Shows the challenge to the user. Must return quickly; waiting for the user happens afterwards + * while {@link OidcDeviceAuth} polls the token endpoint. + * + * @param challenge the user code, verification URL and timing parameters to show + */ + void promptUser(DeviceAuthorizationChallenge challenge); +} diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java new file mode 100644 index 00000000..92d0f1df --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcAuthException.java @@ -0,0 +1,119 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.auth; + +import io.questdb.client.std.str.DisplaySafe; +import io.questdb.client.std.str.StringSink; + +/** + * Thrown when the OIDC device authorization flow cannot obtain a token. The message is built via + * the fluent {@link #put(CharSequence)} family, backed by a {@link StringSink}. + *

+ * For an OAuth error response (RFC 6749 / RFC 8628), {@link #getOauthError()} returns the + * machine-readable error code (e.g. {@code access_denied}, {@code expired_token}); else {@code null}. + */ +public class OidcAuthException extends RuntimeException { + private final StringSink message = new StringSink(); + private String oauthError; + + public OidcAuthException() { + } + + public OidcAuthException(CharSequence message) { + this.message.put(message); + } + + public OidcAuthException(Throwable cause) { + super(cause); + } + + /** + * Builds an exception from an OAuth error response. + * + * @param error the OAuth {@code error} code, never null + * @param description the optional {@code error_description}, may be null or empty + * @return a new exception carrying the error code + */ + public static OidcAuthException oauthError(CharSequence error, CharSequence description) { + OidcAuthException e = new OidcAuthException(); + e.oauthError = error != null ? error.toString() : null; + e.put("the identity provider returned an error [error=").putSanitized(error); + if (description != null && description.length() > 0) { + e.put(", description=").putSanitized(description); + } + e.put(']'); + return e; + } + + // Whether a character must never reach a terminal or log line, delegated to the shared DisplaySafe + // classifier so the auth layer and Utf16Sink.putAsPrintable judge display safety identically. The + // argument is a code point, not a UTF-16 unit: putSanitized scans with codePointAt, which joins a + // surrogate pair into one code point, so a supplementary-plane format/control char is judged whole + // rather than as two harmless-looking halves (the gap that once let an invisible U+E00xx "tag" char + // through). A lone unpaired surrogate surfaces as a SURROGATE code point and is stripped too. + static boolean isUnsafeForDisplay(int c) { + return DisplaySafe.isUnsafeForDisplay(c); + } + + @Override + public String getMessage() { + return message.toString(); + } + + public String getOauthError() { + return oauthError; + } + + public OidcAuthException put(char ch) { + message.put(ch); + return this; + } + + public OidcAuthException put(CharSequence cs) { + message.put(cs); + return this; + } + + public OidcAuthException put(long value) { + message.put(value); + return this; + } + + // appends untrusted text with display-unsafe chars stripped, so an attacker-influenced IdP error + // string cannot inject ANSI escapes, forge log lines, or smuggle bidi/zero-width formatting when + // the exception message is rendered + private void putSanitized(CharSequence cs) { + if (cs != null) { + for (int i = 0, n = cs.length(); i < n; ) { + final int cp = Character.codePointAt(cs, i); + final int count = Character.charCount(cp); + if (!isUnsafeForDisplay(cp)) { + message.put(cs, i, i + count); + } + i += count; + } + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java new file mode 100644 index 00000000..1a3a8a9b --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java @@ -0,0 +1,1985 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Obtains an OIDC access or id token via the OAuth 2.0 Device Authorization Grant + * (RFC 8628), so a browserless process (remote notebook kernel, container, headless job) + * can sign a human in: the user authorizes on any device while the token request travels + * outbound only. + *

+ * The token works on any auth path the server validates: + *

+ * Typical use, discovering everything from the QuestDB server: + *
{@code
+ * try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
+ *     String token = auth.signIn(); // signs in on first use, then caches and refreshes
+ *     // ... use token as an HTTP Bearer header or a PG-wire _sso password ...
+ * }
+ * }
+ * Or configuring the identity provider explicitly: + *
{@code
+ * OidcDeviceAuth auth = OidcDeviceAuth.builder()
+ *         .clientId("questdb")
+ *         .deviceAuthorizationEndpoint("https://idp.example.com/as/device_authz.oauth2")
+ *         .tokenEndpoint("https://idp.example.com/as/token.oauth2")
+ *         .scope("openid groups")
+ *         .groupsInToken(true)
+ *         .build();
+ * }
+ * {@link #signIn()} serves a cached token while valid, silently refreshes when a refresh token + * exists, otherwise re-runs the interactive flow. An instance lock serializes calls, so two + * sign-ins never start at once. A sign-in waiting for the user holds that lock for the device code + * lifetime (up to 30 minutes), so a concurrent {@link #signIn()} or {@link #clearCache()} blocks + * behind it - but {@link #getToken()} never waits: it fails fast with an + * {@link OidcAuthException} so a request/flush path never stalls. To abort a waiting sign-in, call + * {@link #close()} from another thread; it signals the flow to stop, which then fails with an + * {@link OidcAuthException} rather than polling until the device code expires. Cancellation is seen + * between polls (within ~100ms while waiting out an interval); a poll already in flight is not + * interrupted, so the abort - and {@link #close()} - can take up to one HTTP request timeout (see + * {@link Builder#httpTimeoutMillis(int)}), still far short of the device-code lifetime (a + * {@link DeviceCodePrompt} that blocks in {@code promptUser}, such as the default browser launch, can + * extend that wait by however long it runs). + *

+ * Instances are interactive and hold a network connection; close them when done. Token state is + * in-memory only and does not survive a process restart. + */ +public class OidcDeviceAuth implements QuietCloseable { + public static final String DEFAULT_SCOPE = "openid"; + static final String GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"; + static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; + // fixed clock-skew margin (matches the Python client questdb.auth): getToken() treats a cached token as + // expired this many millis before its real exp, to absorb clock drift and request latency. Not + // configurable; effectiveSkewMillis() caps it at half the token lifetime so a short-lived token is not + // reported expired the instant it is issued. + private static final long CLOCK_SKEW_MILLIS = 30_000L; + // device code TTL when the device authorization response omits (or zeroes) expires_in; matches Python + private static final int DEFAULT_DEVICE_CODE_TTL_SECONDS = 600; + private static final int DEFAULT_HTTP_TIMEOUT_MILLIS = 30_000; + private static final int DEFAULT_POLL_INTERVAL_SECONDS = 5; + // token cache TTL when the token response omits expires_in + private static final int DEFAULT_TOKEN_TTL_SECONDS = 300; + private static final String ERROR_AUTHORIZATION_PENDING = "authorization_pending"; + private static final String ERROR_SLOW_DOWN = "slow_down"; + // the grant_type values are constants, so url-encode them once at class load rather than on every + // device-code poll and token refresh + private static final String GRANT_TYPE_DEVICE_CODE_ENCODED = urlEncode(GRANT_TYPE_DEVICE_CODE); + private static final String GRANT_TYPE_REFRESH_TOKEN_ENCODED = urlEncode(GRANT_TYPE_REFRESH_TOKEN); + private static final HttpClientConfiguration HTTP_CONFIG = DefaultHttpClientConfiguration.INSTANCE; + // a rate-limited identity provider answers 429; the token poll treats it as a transient backoff + private static final String HTTP_STATUS_TOO_MANY_REQUESTS = "429"; + // Token responses carry JWTs (an id token with group claims can be several KB), and a single + // value may arrive split across HTTP fragments. The lexer stashes a split value and rejects it + // past JSON_LEXER_MAX_VALUE_BYTES, so the limit must comfortably exceed any real token or large + // tokens fail to parse with "String is too long". + private static final int JSON_LEXER_CACHE_SIZE = 1024; + private static final int JSON_LEXER_MAX_VALUE_BYTES = 1 << 20; + // upper bound on the device code lifetime (the device authorization response's expires_in), so a + // hostile or buggy provider cannot make the client poll for an absurd duration; matches the Python client + private static final int MAX_DEVICE_CODE_TTL_SECONDS = 1800; + // upper bound on the token cache lifetime (the token response's expires_in), so an absurd or hostile + // value cannot overflow the timing arithmetic or make the client trust a token for absurdly long + private static final int MAX_EXPIRES_IN_SECONDS = 3600; + // upper bound on the poll interval, both the initial value and the growth after a slow_down or 429, so + // a hostile or buggy provider cannot stall the poll loop; matches the Python client + private static final int MAX_POLL_INTERVAL_SECONDS = 60; + // cap bytes drained per response so a hostile/MITM'd server cannot stream an endless body and + // wedge the thread; far above any real OIDC JSON response + private static final int MAX_RESPONSE_BODY_BYTES = 4 * 1024 * 1024; + private static final int POLL_PENDING = 1; + private static final long POLL_SLEEP_SLICE_MILLIS = 100; + private static final int POLL_SLOW_DOWN = 2; + private static final int POLL_SUCCESS = 0; + private static final int POLL_TRANSIENT_ERROR = 3; + private static final int SLOW_DOWN_INCREMENT_SECONDS = 5; + private static final String USER_AGENT = "questdb/java-client-oidc"; + private static final String WELL_KNOWN_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration"; + private final String audienceEncoded; + private final String clientIdEncoded; + private final DeviceAuthorizationResponseParser deviceAuthParser = new DeviceAuthorizationResponseParser(); + private final Endpoint deviceAuthorizationEndpoint; + private final StringSink formSink = new StringSink(); + private final boolean groupsInToken; + private final int httpTimeoutMillis; + // serializes signIn()/getToken()/clearCache()/close(); signIn() holds it for the whole + // interactive flow, getToken() uses tryLock so the flush path never stalls behind a sign-in + private final ReentrantLock lock = new ReentrantLock(); + private final DeviceCodePrompt prompt; + private final StringSink responseStatus = new StringSink(); + private final String scopeEncoded; + private final 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; + // lifetime in millis of the currently cached token (its clamped TTL); effectiveSkewMillis() caps the + // clock skew at half of this so a short-lived token is not treated as expired the instant it is issued + private long tokenTtlMillis; + + private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) { + String clientId = builder.clientId; + // pre-encode the invariant form params once here, so the poll loop and silent refresh do not + // re-run URLEncoder on every request (mirrors the pre-encoded GRANT_TYPE_* constants) + this.clientIdEncoded = urlEncode(clientId); + this.deviceAuthorizationEndpoint = Endpoint.parse(builder.deviceAuthorizationEndpoint); + this.tokenEndpoint = Endpoint.parse(builder.tokenEndpoint); + String scope = builder.scope; + this.scopeEncoded = urlEncode(scope); + String audience = builder.audience; + this.audienceEncoded = audience != null ? urlEncode(audience) : null; + this.groupsInToken = builder.groupsInToken; + this.httpTimeoutMillis = builder.httpTimeoutMillis; + this.prompt = builder.prompt; + this.tlsConfig = tlsConfig; + // allocate the native lexer last: an Endpoint.parse above can throw on a malformed url, and + // the half-built instance is never returned, so close() could not free an earlier alloc + this.jsonLexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Discovers the OIDC configuration from a running QuestDB server and builds an instance. + * Reads the public {@code /settings} endpoint (no auth) for the client id, scope, token + * endpoint, device authorization endpoint and groups-in-token mode. + *

+ * Trust model: the endpoints the user signs in against come from the server's + * unauthenticated {@code /settings} response, so a spoofed, compromised, or MITM'd server can + * redirect the whole sign-in to an attacker-controlled identity provider and harvest the + * authorization. Only call {@code fromQuestDB} against a trusted server reached over {@code https} + * (required by default; {@link Builder#allowInsecureTransport(boolean)} removes that protection). + * For an untrusted server, configure the identity provider explicitly with {@link #builder()}, or + * pin it via {@link #fromQuestDB(String, DiscoveryOptions)} and {@link DiscoveryOptions#issuer(String)}. + * + * @param questdbUrl the QuestDB HTTP base URL, for example {@code https://questdb.example.com:9000} + * @return a configured, ready-to-use instance + * @throws OidcAuthException if the server has OIDC disabled, or does not advertise a device + * authorization endpoint and no issuer was pinned to discover it + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl) { + return fromQuestDB(questdbUrl, new DiscoveryOptions()); + } + + /** + * Discovers the OIDC configuration from a running QuestDB server, like {@link #fromQuestDB(String)}, + * but with explicit {@link DiscoveryOptions}: an identity provider pin (issuer), a TLS configuration, an + * insecure-transport opt-in, and the device code prompt - for example + * {@link DeviceCodePrompt#openBrowser()} to also open the verification URL in a browser. + * + * @param questdbUrl the QuestDB HTTP base URL, for example {@code https://questdb.example.com:9000} + * @param options how to pin the identity provider, configure TLS, permit insecure transport, and + * show the device code challenge; see {@link DiscoveryOptions} + * @return a configured, ready-to-use instance + * @throws OidcAuthException if the server has OIDC disabled, or does not advertise a device + * authorization endpoint and no issuer was pinned + */ + public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions options) { + String issuer = options.issuer; + ClientTlsConfiguration tlsConfig = options.tlsConfig != null ? options.tlsConfig : defaultTlsConfig(); + boolean allowInsecureTransport = options.allowInsecureTransport; + Endpoint server = Endpoint.parse(questdbUrl); + if (!allowInsecureTransport) { + requireSecureTransport(server.isTls, "QuestDB server url", questdbUrl); + } + SettingsDiscoveryParser parser = new SettingsDiscoveryParser(); + discoverSettings(server, tlsConfig, parser); + if (!parser.isOidcEnabled) { + throw new OidcAuthException().put("OIDC is not enabled on the QuestDB server [url=").put(questdbUrl).put(']'); + } + if (parser.clientId.length() == 0) { + throw new OidcAuthException().put("the QuestDB server does not advertise an OIDC client id [url=").put(questdbUrl).put(']'); + } + String tokenEndpoint = parser.tokenEndpoint.length() > 0 ? parser.tokenEndpoint.toString() : null; + String deviceAuthorizationEndpoint = parser.deviceAuthorizationEndpoint.length() > 0 ? parser.deviceAuthorizationEndpoint.toString() : null; + String resolvedIssuer = issuer != null && !issuer.isEmpty() ? issuer : null; + // capture each endpoint's provenance before discovery may fill a missing one: only an endpoint the + // untrusted /settings response advertised is origin-pinned to the issuer below. An endpoint discovered + // from the provider's own .well-known is authoritative for wherever the pinned issuer hosts it. + final boolean tokenEndpointFromSettings = tokenEndpoint != null; + final boolean deviceEndpointFromSettings = deviceAuthorizationEndpoint != null; + + // Over a plaintext, MITM-able http /settings channel (only reachable with allowInsecureTransport; + // the default rejects it), advertised endpoints can be tampered in transit to route the device + // code and long-lived refresh token to an attacker. The missing-endpoint discovery path below + // already demands an out-of-band pin, but a tampered /settings advertising BOTH endpoints at one + // attacker origin skips that path - the co-location check passes trivially and there is no issuer + // to pin against - so require the same pin before trusting /settings endpoints over such a channel. + boolean settingsSuppliedCredentials = tokenEndpoint != null || deviceAuthorizationEndpoint != null; + if (settingsSuppliedCredentials && resolvedIssuer == null && settingsChannelIsPlaintext(server)) { + throw new OidcAuthException() + .put("the QuestDB server was reached over insecure http, so its /settings response - and the OIDC ") + .put("endpoints it advertises - can be tampered in transit and used to redirect the device-code and ") + .put("refresh-token requests to an attacker; pin the identity provider with an issuer (its origin, for ") + .put("example https://your-idp), configure the endpoints explicitly with OidcDeviceAuth.builder(), or ") + .put("connect to QuestDB over https [url=").put(questdbUrl).put(']'); + } + + // For /settings-supplied endpoints with an out-of-band issuer, require each under the issuer's PATH, + // not just its origin (validateEndpointOrigins): a path-based identity provider shares one origin per + // tenant (Keycloak issuers are https://host/realms/), so the origin check alone cannot stop a + // tampered /settings from steering credentials to a different realm. The issuer is supplied out of + // band and cannot be forged. Endpoints discovered from the identity provider below are not scoped this + // way - some providers (for example Azure AD) place their endpoints outside the issuer path. + if (issuer != null && !issuer.isEmpty()) { + if (tokenEndpoint != null && !isEndpointUnderIssuerPath(tokenEndpoint, issuer)) { + throw endpointNotUnderIssuer("token endpoint", tokenEndpoint, issuer); + } + if (deviceAuthorizationEndpoint != null && !isEndpointUnderIssuerPath(deviceAuthorizationEndpoint, issuer)) { + throw endpointNotUnderIssuer("device authorization endpoint", deviceAuthorizationEndpoint, issuer); + } + } + + // Fall back to identity provider discovery when the server omits the device authorization endpoint + // (and/or the token endpoint). The provider's origin must be pinned out of band: the discovery + // target is never derived from a server-supplied value, else a tampered or intercepted /settings + // could steer discovery - and the credential POSTs - to an attacker while the co-location and + // issuer checks pass trivially. + if (deviceAuthorizationEndpoint == null || tokenEndpoint == null) { + if (resolvedIssuer == null) { + throw new OidcAuthException() + .put("the QuestDB server did not advertise the OIDC device authorization endpoint (and/or the token ") + .put("endpoint), so it must be discovered from the identity provider, but the identity provider is not ") + .put("pinned; pass an issuer (its origin, for example https://your-idp) to OidcDeviceAuth.fromQuestDB so ") + .put("a tampered or intercepted /settings response cannot redirect the device-code and refresh-token ") + .put("requests to an attacker, or configure the endpoints explicitly with OidcDeviceAuth.builder() [url=") + .put(questdbUrl).put(']'); + } + WellKnownDiscoveryParser doc = new WellKnownDiscoveryParser(); + discoverFromIdp(resolvedIssuer, tlsConfig, allowInsecureTransport, doc); + if (deviceAuthorizationEndpoint == null && doc.deviceAuthorizationEndpoint.length() > 0) { + deviceAuthorizationEndpoint = doc.deviceAuthorizationEndpoint.toString(); + } + if (tokenEndpoint == null && doc.tokenEndpoint.length() > 0) { + tokenEndpoint = doc.tokenEndpoint.toString(); + } + } + + // Pin the ORIGIN of any endpoint the untrusted /settings response advertised to the pinned issuer + // origin, so a tampered /settings cannot redirect the device code and refresh token to + // an attacker. An endpoint discovered from the identity provider's own .well-known is deliberately NOT + // origin-pinned: that document is fetched from the pinned origin and is authoritative for wherever the + // issuer hosts its endpoints - some providers (for example Google) serve the token and device endpoints + // from a different origin than the issuer. The co-location check (token and device share one origin) + // still applies to every endpoint, enforced by validateEndpointOrigins in build(). + if (resolvedIssuer != null) { + Endpoint pin = Endpoint.parse(resolvedIssuer); + if (tokenEndpointFromSettings && !sameOrigin(Endpoint.parse(tokenEndpoint), pin)) { + throw endpointOriginNotPinned("token endpoint", tokenEndpoint, originOf(pin)); + } + if (deviceEndpointFromSettings && !sameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) { + throw endpointOriginNotPinned("device authorization endpoint", deviceAuthorizationEndpoint, originOf(pin)); + } + } + + if (tokenEndpoint == null) { + throw new OidcAuthException() + .put("could not resolve the OIDC token endpoint from the QuestDB /settings response or the identity ") + .put("provider discovery document; configure it explicitly with OidcDeviceAuth.builder() [url=").put(questdbUrl).put(']'); + } + if (deviceAuthorizationEndpoint == null) { + throw new OidcAuthException() + .put("could not resolve the device authorization endpoint; the identity provider discovery document did ") + .put("not advertise \"device_authorization_endpoint\". Ensure the identity provider supports the device ") + .put("grant, or configure the endpoint explicitly with OidcDeviceAuth.builder() [url=").put(questdbUrl).put(']'); + } + return builder() + .clientId(parser.clientId.toString()) + .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint) + .tokenEndpoint(tokenEndpoint) + .scope(parser.scope.length() > 0 ? parser.scope.toString() : DEFAULT_SCOPE) + .audience(parser.audience.length() > 0 ? parser.audience.toString() : null) + .groupsInToken(parser.groupsInToken) + .allowInsecureTransport(allowInsecureTransport) + .tlsConfig(tlsConfig) + .prompt(options.prompt) + .build(); + } + + /** + * Drops any cached token so the next {@link #signIn()} starts a fresh interactive sign-in. + */ + public void clearCache() { + lock.lock(); + try { + throwIfClosed(); + accessToken = null; + idToken = null; + refreshToken = null; + expiresAtMillis = 0; + tokenTtlMillis = 0; + } finally { + lock.unlock(); + } + } + + /** + * Frees the network connections and native buffers this instance holds. If a {@link #signIn()} + * sign-in is in flight on another thread, signals it to stop so it fails with an + * {@link OidcAuthException} instead of polling until the device code expires. The signal is observed + * between polls (within ~100ms while waiting out a poll interval); a poll request already in flight + * is not interrupted, so {@code close()} acquires the lock - and returns - only once that request + * finishes or times out, i.e. after at most one HTTP request timeout + * (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. The exception is a + * {@link DeviceCodePrompt} that blocks in {@code promptUser} - for example the default + * {@link DeviceCodePrompt#openBrowser()} prompt while it hands the verification URL to the OS browser, + * which is not bounded by the HTTP timeout: the flow holds the lock across that one-off prompt, so a + * racing {@code close()} waits it out too. Idempotent. After close, {@link #signIn()}, + * {@link #getToken()} and {@link #clearCache()} throw. + */ + @Override + public void close() { + // flag cancellation before taking the lock: signIn() holds it for the whole flow, so signal the + // in-flight sign-in to stop via a lock-free volatile write, then acquire the lock - released by the + // cancelled flow once it observes the flag (between polls, or after an in-flight poll returns) - and + // free the native resources. close() never frees while a flow holds the lock, so no use-after-free + closed = true; + lock.lock(); + try { + plainClient = Misc.free(plainClient); + tlsClient = Misc.free(tlsClient); + jsonLexer = Misc.free(jsonLexer); + } finally { + lock.unlock(); + } + } + + /** + * @return {@code "Bearer " + signIn()}, ready to use as the value of an HTTP + * {@code Authorization} header. + */ + public String getAuthorizationHeaderValue() { + return "Bearer " + signIn(); + } + + /** + * Like {@link #signIn()} but never starts the interactive device flow, never prompts, and never waits + * on interactive input: returns the cached token while valid, silently refreshes when a refresh token is + * available, otherwise throws. Designed for the request/flush path of a long-lived client, for example + * {@code Sender.builder(...).httpTokenProvider(auth::getToken)}, where an interactive prompt is + * inappropriate. Call {@link #signIn()} once to sign in first. + *

+ * It does not wait behind an interactive {@link #signIn()} running on another thread (which would stall + * the flush for the whole device-code lifetime): if such a sign-in holds the lock it fails fast, and the + * caller should retry once the sign-in completes. It is not, however, instantaneous - when the cached + * token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by + * {@link Builder#httpTimeoutMillis(int)} (30s by default). That is the "quick silent refresh" the + * {@code HttpTokenProvider} contract permits on the flush path, not an unbounded interactive wait. + * + * @return a non-null, non-empty token + * @throws OidcAuthException if no token has been obtained yet, if the cached token expired and could + * not be refreshed without an interactive sign-in, or if a sign-in or + * refresh is already in progress on another thread + */ + public String getToken() { + throwIfClosed(); + // never wait on the flush path: signIn()'s sign-in holds the lock for the whole device-code + // lifetime (up to 30 minutes), so tryLock and fail fast if held. A sign-in in progress means there + // is no token to serve yet, so the caller gets a prompt exception to retry rather than a stalled + // flush + if (!lock.tryLock()) { + throw new OidcAuthException("a sign-in or token refresh is already in progress on another thread; no token is available without blocking - retry shortly"); + } + try { + throwIfClosed(); + final String cachedToken = groupsInToken ? idToken : accessToken; + if (cachedToken != null) { + if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { + 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 signIn() to sign in again"); + } + throw new OidcAuthException("no token has been obtained yet; call signIn() to sign in before using getToken()"); + } finally { + lock.unlock(); + } + } + + /** + * Returns a valid token to present to QuestDB: the cached token while still valid, otherwise a + * silent refresh when possible, otherwise the interactive device flow. The token is the id token + * when the server expects groups encoded in the token, the access token otherwise. + * + * @return a non-null, non-empty token + * @throws OidcAuthException if the interactive flow fails, times out, or the identity provider + * does not return the expected token + */ + public String signIn() { + lock.lock(); + try { + throwIfClosed(); + // only the kind of token signIn() actually serves counts as a cache hit; a grant that + // returned the other kind (access token when the server wants the id token, or vice versa) + // leaves the served token null, so re-run the flow rather than report the unusable grant as + // valid and have selectToken() throw on this and every later call + final String cachedToken = groupsInToken ? idToken : accessToken; + if (cachedToken != null) { + if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) { + return cachedToken; + } + if (refreshToken != null && tryRefresh()) { + return selectToken(); + } + } + runDeviceFlow(); + return selectToken(); + } finally { + lock.unlock(); + } + } + + private static String appendSettingsPath(String basePath) { + String trimmed = basePath; + while (trimmed.length() > 1 && trimmed.charAt(trimmed.length() - 1) == '/') { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return "/".equals(trimmed) ? "/settings" : trimmed + "/settings"; + } + + private static int boundedSeconds(int value, int defaultValue, int maxValue) { + if (value <= 0) { + return defaultValue; + } + return Math.min(value, maxValue); + } + + private static String[] decodePathSegments(String path) { + // Repeatedly percent-decode (a server or proxy may unescape more than once, so %252e%252e -> .. ) + // and fold backslash to slash (some proxies do), then split into segments. Comparing these decoded + // segments, not the raw wire string, means an encoding the server later undoes cannot hide a "..". + String decoded = path; + for (int i = 0; i < 10; i++) { // bounded; a real path needs 0-1 passes + String next = percentDecodeOnce(decoded); + if (next.equals(decoded)) { + break; + } + decoded = next; + } + return decoded.replace('\\', '/').split("/", -1); + } + + private static ClientTlsConfiguration defaultTlsConfig() { + return new ClientTlsConfiguration(null, null, ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL); + } + + private static boolean discardBody(Response body, int timeoutMillis) { + // best-effort drain after a parse failure to keep the keep-alive connection usable; bounded like + // parseBody so a hostile server cannot wedge the thread here either. Returns true only when the body + // was fully drained (so the connection can be reused); returns false when the drain stopped early - + // on the deadline, the byte cap, or a transport error - leaving unconsumed bytes, so the caller must + // drop the connection rather than parse this response's leftovers on the next request. + final long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; + long totalBytes = 0; + try { + while (true) { + final long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + return false; + } + Fragment fragment = body.recv((int) Math.max(1, Math.min(remainingNanos / 1_000_000L, Integer.MAX_VALUE))); + if (fragment == null) { + return true; + } + totalBytes += fragment.hi() - fragment.lo(); + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + return false; + } + } + } catch (HttpClientException ignore) { + return false; + } + } + + private static void discoverFromIdp(String issuer, ClientTlsConfiguration tlsConfig, boolean allowInsecureTransport, WellKnownDiscoveryParser parser) { + // the issuer is pinned out of band (the caller guarantees it is non-null), so the server cannot choose + // where discovery - and the credential POSTs it resolves - are aimed + String url = wellKnownUrl(issuer); + Endpoint endpoint = Endpoint.parse(url); + requireSecureIdpEndpoint(endpoint, "OIDC issuer", url, allowInsecureTransport); + fetchJson(endpoint, endpoint.path, tlsConfig, parser, + "could not reach the identity provider to discover OIDC settings", + "could not parse the identity provider discovery document"); + } + + private static void discoverSettings(Endpoint server, ClientTlsConfiguration tlsConfig, SettingsDiscoveryParser parser) { + fetchJson(server, appendSettingsPath(server.path), tlsConfig, parser, + "could not reach the QuestDB server to discover OIDC settings", + "could not parse the QuestDB /settings response"); + } + + private static OidcAuthException endpointNotUnderIssuer(String label, String url, String issuer) { + return new OidcAuthException() + .put("the OIDC ").put(label).put(" advertised by the QuestDB /settings response (").put(url) + .put(") is not under the pinned issuer (").put(issuer).put("); refusing to send credentials to ") + .put("an endpoint outside the trusted issuer, for example a different realm on the same host; ") + .put("if the identity provider places its endpoints outside the issuer path, configure them ") + .put("explicitly with OidcDeviceAuth.builder()"); + } + + private static OidcAuthException endpointOriginNotPinned(String label, String url, String pinOrigin) { + return new OidcAuthException() + .put("the OIDC ").put(label).put(" advertised by the QuestDB /settings response (").put(url) + .put(") is not on the pinned identity-provider origin (").put(pinOrigin).put("); refusing to send ") + .put("credentials to an endpoint outside the trusted issuer. If the identity provider hosts its ") + .put("endpoints on a different origin than its issuer, configure them explicitly with ") + .put("OidcDeviceAuth.builder()"); + } + + private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) { + // Scan for a literal backslash (decodePathSegments folds it to '/') or a percent-encoded path + // separator - %2f ('/'), %5c ('\'), or an encoded percent %25 that gates a split or double encoding + // such as %2%66 or %252f - at every decode level, not just the raw string. A separator that only + // emerges after the server unescapes more than once would pass a single-pass scan yet split one + // segment in two, letting .../realms/acme%2%66evil/token slip the issuer-path scope. A real OIDC + // endpoint path encodes none of these. Bounded like decodePathSegments; a real path needs 0-1 passes. + String decoded = rawEndpointPath; + for (int pass = 0; pass < 10; pass++) { + for (int i = 0, n = decoded.length(); i < n; i++) { + char c = decoded.charAt(i); + if (c == '\\') { + return true; + } + if (c == '%' && i + 2 < n) { + char a = decoded.charAt(i + 1); + char b = decoded.charAt(i + 2); + if ((a == '2' && (b == 'f' || b == 'F' || b == '5')) || (a == '5' && (b == 'c' || b == 'C'))) { + return true; + } + } + } + String next = percentDecodeOnce(decoded); + if (next.equals(decoded)) { + break; + } + decoded = next; + } + return false; + } + + private static void fetchJson(Endpoint endpoint, String path, ClientTlsConfiguration tlsConfig, JsonParser parser, String reachError, String parseError) { + HttpClient client = endpoint.isTls + ? HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig) + : HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); + // allocate the native lexer inside the try: new JsonLexer mallocs and can throw (native OOM), and + // the client is already allocated, so a throw before the try is entered would skip the finally and + // leak the client's native buffers + JsonLexer lexer = null; + try { + lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES); + HttpClient.Request request = client.newRequest(endpoint.host, endpoint.port) + .GET() + .url(path) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT); + HttpClient.ResponseHeaders response = request.send(DEFAULT_HTTP_TIMEOUT_MILLIS); + response.await(DEFAULT_HTTP_TIMEOUT_MILLIS); + Response body = response.getResponse(); + // parseBody enforces a wall-clock deadline and a byte cap so an untrusted server cannot wedge + // discovery, and its parseLast rejects a truncated document + parseBody(body, lexer, parser, DEFAULT_HTTP_TIMEOUT_MILLIS); + } catch (HttpClientException e) { + throw new OidcAuthException(e).put(reachError); + } catch (JsonException e) { + throw new OidcAuthException(e).put(parseError); + } finally { + Misc.free(lexer); + Misc.free(client); + } + } + + private static int hexValue(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + private static boolean isDottedIpv4(String host) { + // validate a dotted IPv4 literal (four 0-255 octets) without DNS, so a hostname merely starting + // with "127." is not mistaken for the loopback block + int octets = 1; + int value = 0; + int digits = 0; + for (int i = 0, n = host.length(); i < n; i++) { + char c = host.charAt(i); + if (c == '.') { + if (digits == 0 || value > 255) { + return false; + } + octets++; + value = 0; + digits = 0; + } else if (c >= '0' && c <= '9') { + value = value * 10 + (c - '0'); + if (++digits > 3) { + return false; + } + } else { + return false; + } + } + return octets == 4 && digits > 0 && value <= 255; + } + + private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issuer) { + // The endpoint's path must be the issuer's path or a sub-path of it, compared segment by segment (so + // /realms/prod does not match /realms/production). A root issuer (no path) constrains the origin only. + // This stops a tampered /settings from redirecting credentials to a different tenant on a path-based + // multi-tenant identity provider (Keycloak issuers are https://host/realms/), which the origin + // check alone cannot catch. Mirrors the Python client. + String basePath = pathOnly(issuer); + int baseEnd = basePath.length(); + while (baseEnd > 0 && basePath.charAt(baseEnd - 1) == '/') { + baseEnd--; // trailing slashes do not add a path segment + } + if (baseEnd == 0) { + return true; // root issuer: origin-only, every path is under it + } + String[] baseSegs = decodePathSegments(basePath.substring(0, baseEnd)); + String rawEndpointPath = pathOnly(endpointUrl); + // A real OIDC endpoint path never encodes a path separator or uses a backslash; reject either before + // the segment comparison, since decodePathSegments resolves them and would split one path segment in + // two, letting .../realms/acme%2fevil/token (or its split/backslash forms) slip the issuer-path scope. + if (endpointPathHasEncodedSeparator(rawEndpointPath)) { + return false; + } + String[] endpointSegs = decodePathSegments(rawEndpointPath); + // a "." or ".." segment is rejected outright: the server normalizes it away, so a naive prefix test + // would pass /realms/acme/../evil/token yet it resolves to a different realm + for (int i = 0; i < endpointSegs.length; i++) { + if (".".equals(endpointSegs[i]) || "..".equals(endpointSegs[i])) { + return false; + } + } + if (endpointSegs.length < baseSegs.length) { + return false; + } + for (int i = 0; i < baseSegs.length; i++) { + if (!baseSegs[i].equals(endpointSegs[i])) { + return false; + } + } + return true; + } + + private static boolean isLoopbackHost(String host) { + // loopback traffic never leaves the host, so a plaintext /settings fetch to it has no network + // interception risk; match localhost and the whole IPv4 127.0.0.0/8 block + return host != null && (host.equalsIgnoreCase("localhost") || (host.startsWith("127.") && isDottedIpv4(host))); + } + + 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 a wall-clock deadline and a cumulative byte cap, so a + // hostile or stalled server cannot wedge the thread by dribbling or endlessly streaming + final long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; + long totalBytes = 0; + while (true) { + final long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new HttpClientException("timed out reading the identity provider response body"); + } + Fragment fragment = body.recv((int) Math.max(1, Math.min(remainingNanos / 1_000_000L, Integer.MAX_VALUE))); + if (fragment == null) { + break; + } + totalBytes += fragment.hi() - fragment.lo(); + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + throw new HttpClientException("the identity provider response body exceeded the size limit"); + } + lexer.parse(fragment.lo(), fragment.hi(), parser); + } + lexer.parseLast(); // reject a truncated body (unterminated string/object) + } + + private static int parseIntOrZero(CharSequence value) { + try { + return Numbers.parseInt(value); + } catch (NumericException e) { + return 0; + } + } + + private static String pathOnly(String url) { + // the path component only (drop any ?query / #fragment); a ;matrix parameter stays part of the path, + // so a traversal hidden in it (.../token;..%2f..) is still scanned + String path = Endpoint.parse(url).path; + for (int i = 0, n = path.length(); i < n; i++) { + char c = path.charAt(i); + if (c == '?' || c == '#') { + return path.substring(0, i); + } + } + return path; + } + + private static String percentDecodeOnce(String s) { + int pct = s.indexOf('%'); + if (pct < 0) { + return s; // nothing encoded + } + StringSink sink = new StringSink(); + sink.put(s, 0, pct); + for (int i = pct, n = s.length(); i < n; ) { + char c = s.charAt(i); + if (c == '%' && i + 2 < n) { + int hi = hexValue(s.charAt(i + 1)); + int lo = hexValue(s.charAt(i + 2)); + if (hi >= 0 && lo >= 0) { + sink.put((char) ((hi << 4) | lo)); + i += 3; + continue; + } + } + sink.put(c); + i++; + } + return sink.toString(); + } + + private static void putNonNull(StringSink sink, CharSequence tag) { + // clear before storing so a repeated key replaces, not concatenates onto, the previous value; a + // JSON null arrives from the lexer as the literal "null", so treat it as absent rather than store + // the 4-char string "null" as a token, error code, endpoint or user code + sink.clear(); + if (!Chars.equals("null", tag)) { + sink.put(tag); + } + } + + private static void requireSecureIdpEndpoint(Endpoint endpoint, String label, String url, boolean allowInsecureTransport) { + // https is always fine; plaintext http is allowed only to a loopback host, where the request never + // leaves the machine. allowInsecureTransport relaxes the QuestDB link but never the identity + // provider: the device code and refresh token must not cross the network in cleartext (matching + // the Python client) + if (endpoint.isTls || isLoopbackHost(endpoint.host)) { + return; + } + OidcAuthException ex = new OidcAuthException() + .put("the ").put(label).put(" uses insecure http, which would send the device code and ") + .put("refresh token across the network in cleartext; use an https url"); + if (allowInsecureTransport) { + ex.put(" (allowInsecureTransport relaxes only the QuestDB connection, not the identity provider endpoints)"); + } + throw ex.put(" [url=").put(url).put(']'); + } + + private static void requireSecureTransport(boolean isTls, String label, String url) { + if (!isTls) { + throw new OidcAuthException() + .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 boolean sameOrigin(Endpoint a, Endpoint b) { + // scheme (via isTls), host and port - the security origin; path is deliberately not compared, the + // token and device endpoints legitimately differ in path on one authorization server + return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host); + } + + private static String sanitizeForDisplay(String value) { + if (value == null) { + return null; + } + final int n = value.length(); + int firstUnsafe = -1; + for (int i = 0; i < n; ) { + final int cp = value.codePointAt(i); + if (OidcAuthException.isUnsafeForDisplay(cp)) { + firstUnsafe = i; + break; + } + i += Character.charCount(cp); + } + if (firstUnsafe < 0) { + return value; // common case: nothing to strip + } + // an attacker-influenced device-auth field can smuggle in terminal-spoofing characters - ANSI + // escapes, CR/LF, or bidi/zero-width formatting (including supplementary-plane "tag" chars that + // arrive as surrogate pairs) - that reorder or hide text, so strip them per code point; else a + // right-to-left override could make the verification URL a human reads differ from the one their + // browser opens + StringSink sink = new StringSink(); + sink.put(value, 0, firstUnsafe); + for (int i = firstUnsafe; i < n; ) { + final int cp = value.codePointAt(i); + final int count = Character.charCount(cp); + if (!OidcAuthException.isUnsafeForDisplay(cp)) { + sink.put(value, i, i + count); + } + i += count; + } + return sink.toString(); + } + + private static boolean settingsChannelIsPlaintext(Endpoint server) { + // /settings over plaintext http to a non-loopback host is MITM-able (only possible with + // allowInsecureTransport; the default rejects it), so its advertised endpoints must not be trusted + // to route credentials without an out-of-band pin + return !server.isTls && !isLoopbackHost(server.host); + } + + private static String urlEncode(String value) { + try { + // the Charset overload is Java 10; the client targets Java 8, so use the String-charset form + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // UTF-8 is guaranteed present on every JVM, so this is unreachable; rethrow defensively + throw new OidcAuthException(e).put("UTF-8 encoding is not supported"); + } + } + + private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint deviceAuthorizationEndpoint, Endpoint issuer) { + // the device code and long-lived refresh token are POSTed to the device authorization and token + // endpoints. RFC 8628 co-locates them on one authorization server, so reject a config that splits + // them across origins (a tampered /settings or discovery document siphoning one off) on every + // construction path. The issuer-origin pin here is the explicit builder().issuer() opt-in - a sanity + // check that user-supplied endpoints sit on the pinned origin; a provider hosting its endpoints off + // the issuer origin must then be configured without an issuer. fromQuestDB pins differently: it + // origin-pins only the /settings-advertised endpoints itself (a discovered endpoint is trusted), so + // it passes no issuer here and relies on this method only for the co-location check. + if (!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 void validateTokenChars(CharSequence token, String tokenName) { + // The selected token goes verbatim into the "Authorization: Bearer " header sent to the + // trusted QuestDB server and into the PG-wire _sso password. A CR/LF or other control char would + // break out of the header into the request line (the lexer now decodes a \r or \n escape in the + // provider's response into a real control byte), and a non-ASCII char is silently truncated to one + // byte by the ASCII header writer. A real OAuth token is printable ASCII, so reject anything else + // rather than route a tampered or corrupt credential onto the wire. Token bytes are never embedded + // in the message: they are the secret this class protects. + for (int i = 0, n = token.length(); i < n; i++) { + char c = token.charAt(i); + if (c < 0x20 || c > 0x7e) { + throw new OidcAuthException() + .put("the identity provider returned an ").put(tokenName) + .put(" containing a disallowed control or non-ASCII character; refusing to use it as a credential"); + } + } + } + + private static String wellKnownUrl(String issuer) { + String trimmed = issuer; + while (trimmed.length() > 1 && trimmed.charAt(trimmed.length() - 1) == '/') { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed + WELL_KNOWN_OPENID_CONFIGURATION_PATH; + } + + private void appendEncodedParam(StringSink sink, String name, String encodedValue) { + sink.putAscii('&').putAscii(name).putAscii('=').putAscii(encodedValue); + } + + private void appendParam(StringSink sink, String name, String value) { + sink.putAscii('&').putAscii(name).putAscii('=').putAscii(urlEncode(value)); + } + + private long effectiveSkewMillis() { + // mirror the Python client's TokenSet.is_valid: cap the fixed 30s skew at half the token lifetime, so + // a short-lived (< 60s) token is not treated as expired the instant it is issued. With an unknown + // lifetime (no token cached yet), fall back to the full skew. + if (tokenTtlMillis <= 0) { + return CLOCK_SKEW_MILLIS; + } + return Math.min(CLOCK_SKEW_MILLIS, tokenTtlMillis / 2); + } + + private HttpClient httpClient(boolean isTls) { + if (isTls) { + if (tlsClient == null) { + tlsClient = HttpClientFactory.newTlsInstance(HTTP_CONFIG, tlsConfig); + } + return tlsClient; + } + if (plainClient == null) { + plainClient = HttpClientFactory.newPlainTextInstance(HTTP_CONFIG); + } + return plainClient; + } + + private boolean isHttpStatusSuccess() { + // responseStatus is the bare-digit HTTP status captured by readResponse. A real status is exactly 3 + // digits, so require that before reading the leading digit: a malformed short status such as "2" must + // not be mistaken for a 2xx success and accepted as a grant. + return responseStatus.length() == 3 && responseStatus.charAt(0) == '2'; + } + + private boolean isHttpStatusTerminal4xx() { + // a 4xx other than 429 is a terminal client-error rejection (429 is a transient rate-limit); require a + // full 3-digit status so a malformed short "4" is not classified as a terminal 4xx + return responseStatus.length() == 3 && responseStatus.charAt(0) == '4' && !Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus); + } + + private boolean isHttpStatusTransient() { + // a 5xx server error or a 429 rate-limit is transient - keep polling; any other non-2xx (a 4xx + // rejection) is terminal. Mirrors the Python client's _http_status_is_transient. Require a full + // 3-digit status so a malformed short "5" is not classified as a transient 5xx. + return responseStatus.length() == 3 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus)); + } + + private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) { + // url-encode the opaque device code once here, not on every poll: it is invariant for the whole + // poll loop (the grant_type and client_id are likewise pre-encoded) + final String deviceCodeEncoded = urlEncode(deviceCode); + final long deadlineNanos = System.nanoTime() + expiresInSeconds * 1_000_000_000L; + long intervalMillis = (long) intervalSeconds * 1000L; + while (true) { + throwIfClosed(); + // check the deadline before polling so an expiry that elapsed during the previous sleep aborts + // here, not after one more wasted poll round-trip + if (System.nanoTime() >= deadlineNanos) { + throw new OidcAuthException("timed out waiting for authorization, the device code expired; please retry"); + } + try { + int result = pollOnce(deviceCodeEncoded); + if (result == POLL_SUCCESS) { + return; + } + if (result == POLL_SLOW_DOWN) { + // grow the interval per RFC 8628, capped at the same bound as the initial value so + // repeated slow_down / 429 responses cannot inflate the wait without bound + intervalMillis = Math.min(intervalMillis + SLOW_DOWN_INCREMENT_SECONDS * 1000L, MAX_POLL_INTERVAL_SECONDS * 1000L); + } + // POLL_PENDING and POLL_TRANSIENT_ERROR (a transient 5xx) just poll again + } catch (HttpClientException e) { + // a transport failure (dropped connection, DNS blip, timeout) is transient: the user may + // already have authorized, and RFC 8628 expects polling to continue until the device code + // expires, so poll again rather than discard the sign-in (the deadline bounds the total + // wait). Matches the Python client. + } catch (OidcAuthException e) { + // a garbled / non-JSON body (a JsonException cause) is transient too, UNLESS its HTTP status + // is a terminal rejection (a non-JSON 4xx from a WAF or proxy); a well-formed terminal answer + // - an OAuth error, a terminal 4xx, a malformed status line - always aborts + if (!(e.getCause() instanceof JsonException) || isHttpStatusTerminal4xx()) { + throw e; + } + } + // wait for the next poll, never past the device-code deadline, so the timeout check at the top + // of the loop fires promptly at expiry instead of up to one poll interval late + sleepBetweenPolls(Math.min(intervalMillis, (deadlineNanos - System.nanoTime()) / 1_000_000L)); + } + } + + private int pollOnce(String deviceCodeEncoded) { + formSink.clear(); + formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_DEVICE_CODE_ENCODED); + appendEncodedParam(formSink, "device_code", deviceCodeEncoded); + appendEncodedParam(formSink, "client_id", clientIdEncoded); + + tokenParser.clear(); + // a transport failure here propagates to pollForToken, which keeps polling (a transient blip) until + // the device-code deadline rather than swallowing it as a pending authorization + postForm(tokenEndpoint, tokenParser); + + // RFC 6749 5.2: an error response is an error even if the body also carries a token, or the status is + // 429 - so handle the OAuth error first. A terminal error (e.g. access_denied) must abort even when + // the identity provider also rate-limits, and a token smuggled alongside an error must never count as + // a grant. + if (tokenParser.error.length() > 0) { + if (Chars.equals(ERROR_AUTHORIZATION_PENDING, tokenParser.error)) { + return POLL_PENDING; + } + if (Chars.equals(ERROR_SLOW_DOWN, tokenParser.error)) { + return POLL_SLOW_DOWN; + } + throw OidcAuthException.oauthError(tokenParser.error, tokenParser.errorDescription); + } + + // A rate-limited identity provider answers 429 with no OAuth error; RFC 8628 does not define it, but + // the Python client and common practice treat it as "poll slower". Back off and keep polling (like + // slow_down) rather than treating it as a terminal error, so transient rate limiting does not fail + // the sign-in. + if (Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus)) { + return POLL_SLOW_DOWN; + } + // RFC 6749 5.1: a grant is a 2xx response carrying a token; a token under a non-2xx is malformed and + // is not trusted (the non-2xx is classified below instead) + if (isHttpStatusSuccess()) { + if (tokenParser.accessToken.length() > 0 || tokenParser.idToken.length() > 0) { + storeTokens(tokenParser); + return POLL_SUCCESS; + } + // a 2xx with neither a token nor an OAuth error is a definitive but malformed answer + throw new OidcAuthException().put("unexpected response from the token endpoint [httpStatus=").put(responseStatus).put(']'); + } + // a non-2xx with no recognized OAuth error: a 5xx (or 429, handled above) is a transient server or + // gateway condition - keep polling to the deadline; any other status is a terminal rejection (a 4xx + // from the identity provider, a WAF or a proxy) that aborts immediately rather than polling on to a + // misleading "device code expired". Matches the Python client. + if (isHttpStatusTransient()) { + return POLL_TRANSIENT_ERROR; + } + throw new OidcAuthException().put("the token endpoint rejected the request [httpStatus=").put(responseStatus).put("]; refusing to keep polling"); + } + + private void postForm(Endpoint endpoint, JsonParser parser) { + HttpClient client = httpClient(endpoint.isTls); + HttpClient.Request request = client.newRequest(endpoint.host, endpoint.port) + .POST() + .url(endpoint.path) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT); + request.withContent(); + request.putAscii(formSink); + try { + HttpClient.ResponseHeaders response = request.send(httpTimeoutMillis); + response.await(httpTimeoutMillis); + readResponse(client, response, parser); + } catch (HttpClientException e) { + // a transport failure, or a bounded-read abort in parseBody (its wall-clock deadline or the + // MAX_RESPONSE_BODY_BYTES cap), leaves the response half-read with unconsumed bytes in this + // cached keep-alive connection. Drop it so the next poll or refresh reconnects with a clean + // socket instead of parsing the previous response's leftovers - which pollForToken would + // otherwise keep doing, on a corrupted connection, until the device code expires. Mirrors the + // disconnect-on-failure handling in AbstractLineHttpSender.flush0. + client.disconnect(); + throw e; + } + } + + private void readResponse(HttpClient client, HttpClient.ResponseHeaders response, JsonParser parser) { + // capture only the HTTP status for diagnostics; the body is never retained or surfaced in a + // message - it carries access, id and refresh tokens that must not reach logs or exceptions + responseStatus.clear(); + DirectUtf8Sequence statusCode = response.getStatusCode(); + Response body = response.getResponse(); + if (statusCode != null) { + // a well-formed HTTP status code is bare digits, but the header parser copies the status-line + // token verbatim apart from SP/CR/LF, so a non-digit byte means a malformed or hostile status + // line. Reject it rather than echo any byte (which could smuggle ESC or other control sequences + // into a log or terminal when responseStatus is surfaced in a message below) or trust its + // leading digit as a success gate. Drain the body first to keep the keep-alive connection usable; + // if it could not be fully drained, drop the connection so the next request does not read this + // body's leftovers. + CharSequence raw = statusCode.asAsciiCharSequence(); + for (int i = 0, n = raw.length(); i < n; i++) { + char c = raw.charAt(i); + if (c < '0' || c > '9') { + if (!discardBody(body, httpTimeoutMillis)) { + client.disconnect(); + } + throw new OidcAuthException("the identity provider returned a malformed HTTP status code"); + } + responseStatus.put(c); + } + } + jsonLexer.clear(); + try { + parseBody(body, jsonLexer, parser, httpTimeoutMillis); + } catch (JsonException e) { + // drain the rest to keep the keep-alive connection usable; never embed the body, it may carry + // tokens. A body too large to drain within the cap (e.g. a multi-MB malformed response) leaves + // unconsumed bytes, so drop the connection rather than mis-frame the next request's response. + if (!discardBody(body, httpTimeoutMillis)) { + client.disconnect(); + } + throw new OidcAuthException(e) + .put("could not parse the identity provider response [httpStatus=").put(responseStatus).put(']'); + } + } + + private void runDeviceFlow() { + formSink.clear(); + formSink.putAscii("client_id=").putAscii(clientIdEncoded); + appendEncodedParam(formSink, "scope", scopeEncoded); + if (audienceEncoded != null) { + appendEncodedParam(formSink, "audience", audienceEncoded); + } + + deviceAuthParser.clear(); + try { + postForm(deviceAuthorizationEndpoint, deviceAuthParser); + } catch (HttpClientException e) { + throw new OidcAuthException(e).put("could not reach the device authorization endpoint"); + } + + if (deviceAuthParser.error.length() > 0) { + throw OidcAuthException.oauthError(deviceAuthParser.error, deviceAuthParser.errorDescription); + } + // RFC 8628 3.2: a device authorization grant is a 2xx response. A non-2xx body with no OAuth error + // (handled above) is malformed or hostile; reject it rather than prompt the user and poll on it - + // the same 2xx gate pollOnce and tryRefresh apply before trusting a token + if (!isHttpStatusSuccess()) { + throw new OidcAuthException().put("unexpected response from the device authorization endpoint [httpStatus=").put(responseStatus).put(']'); + } + // the device code is sent in the poll requests, not shown, so check it on the wire; the user code and + // verification URL are shown to the user, so sanitize them first and require them non-empty after + // sanitizing - a value made entirely of control/format chars is non-empty on the wire but would + // otherwise display as a blank code or URL + final String deviceCode = deviceAuthParser.deviceCode.toString(); + final String userCode = sanitizeForDisplay(deviceAuthParser.userCode.toString()); + final String verificationUri = sanitizeForDisplay(deviceAuthParser.verificationUri.toString()); + if (deviceCode.isEmpty() || userCode.isEmpty() || verificationUri.isEmpty()) { + throw new OidcAuthException().put("incomplete device authorization response from the identity provider [httpStatus=").put(responseStatus).put(']'); + } + // a verification_uri_complete that is non-empty on the wire but sanitizes to empty is treated as + // absent (null), so the prompt prints no blank "(or open this URL ...)" line and the browser launcher + // is never handed an empty string + String verificationUriComplete = deviceAuthParser.verificationUriComplete.length() > 0 + ? sanitizeForDisplay(deviceAuthParser.verificationUriComplete.toString()) + : null; + if (verificationUriComplete != null && verificationUriComplete.isEmpty()) { + verificationUriComplete = null; + } + + final int expiresInSeconds = boundedSeconds(deviceAuthParser.expiresIn, DEFAULT_DEVICE_CODE_TTL_SECONDS, MAX_DEVICE_CODE_TTL_SECONDS); + final int intervalSeconds = boundedSeconds(deviceAuthParser.interval, DEFAULT_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); + final DeviceAuthorizationChallenge challenge = new DeviceAuthorizationChallenge( + userCode, + verificationUri, + verificationUriComplete, + expiresInSeconds, + intervalSeconds + ); + + throwIfClosed(); + prompt.promptUser(challenge); + pollForToken(deviceCode, expiresInSeconds, intervalSeconds); + } + + private String selectToken() { + if (groupsInToken) { + if (idToken != null) { + return idToken; + } + throw new OidcAuthException() + .put("the server expects groups encoded in the token (acl.oidc.groups.encoded.in.token=true) but the ") + .put("identity provider returned no id_token; ensure the requested scope includes 'openid'"); + } + if (accessToken != null) { + return accessToken; + } + throw new OidcAuthException("the identity provider returned no access_token"); + } + + private void sleepBetweenPolls(long millis) { + // sleep in short slices so close() can abort an in-flight sign-in within ~POLL_SLEEP_SLICE_MILLIS + // instead of after a full (possibly slow_down-inflated) interval; 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) { + // reject a token with control or non-ASCII chars before caching: getToken() serves it verbatim as an + // HTTP Authorization header value and a PG-wire password, where a decoded CR/LF would inject into the + // request line sent to the trusted QuestDB server. Validate only the kind getToken() actually serves + // (the one that reaches the wire); the other kind is cached but never sent, so a stray char in it must + // not abort an otherwise-usable grant. + if (groupsInToken) { + validateTokenChars(parser.idToken, "id_token"); + } else { + validateTokenChars(parser.accessToken, "access_token"); + } + accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null; + idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null; + // a refresh response usually omits a new refresh token; keep the current one in that case + if (parser.refreshToken.length() > 0) { + refreshToken = parser.refreshToken.toString(); + } + // clamp like the device-side expires_in: default for a non-positive value, cap an absurd one, so a + // hostile or buggy token TTL cannot cache the token for decades (the server still enforces the real + // expiry; this only bounds how long the client trusts its cached copy) + int ttlSeconds = boundedSeconds(parser.expiresIn, DEFAULT_TOKEN_TTL_SECONDS, MAX_EXPIRES_IN_SECONDS); + tokenTtlMillis = ttlSeconds * 1000L; + expiresAtMillis = System.currentTimeMillis() + tokenTtlMillis; + } + + private void throwIfClosed() { + if (closed) { + throw new OidcAuthException("the OidcDeviceAuth instance is closed"); + } + } + + private boolean tryRefresh() { + formSink.clear(); + formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED); + appendParam(formSink, "refresh_token", refreshToken); + appendEncodedParam(formSink, "client_id", clientIdEncoded); + appendEncodedParam(formSink, "scope", scopeEncoded); + if (audienceEncoded != null) { + appendEncodedParam(formSink, "audience", audienceEncoded); + } + + tokenParser.clear(); + try { + postForm(tokenEndpoint, tokenParser); + } catch (HttpClientException e) { + // could not reach the token endpoint; fall back to the interactive flow + return false; + } catch (OidcAuthException e) { + // postForm throws OidcAuthException only on a parse failure (a garbled / unparseable refresh + // response), never an OAuth error: a genuine OAuth error arrives in tokenParser.error, handled + // by hasRequiredToken below. So treat this as a transient blip and fall back to the interactive + // flow rather than fail the whole getToken() call + return false; + } + // succeed only on a clean 2xx (no OAuth error) returning the token getToken() actually serves (the + // id token when groups are encoded in it, the access token otherwise). A refresh that omits the id + // token - which RFC 6749 permits and many providers do - or carries an error or a non-2xx status + // must fall back to the interactive flow rather than be cached (and later fail in selectToken()) + boolean hasRequiredToken = (groupsInToken + ? tokenParser.idToken.length() > 0 + : tokenParser.accessToken.length() > 0) + && isHttpStatusSuccess() + && tokenParser.error.length() == 0; + if (hasRequiredToken) { + storeTokens(tokenParser); + return true; + } + // the refresh token expired or was revoked, or 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 String deviceAuthorizationEndpoint; + private boolean groupsInToken; + private int httpTimeoutMillis = DEFAULT_HTTP_TIMEOUT_MILLIS; + private String issuer; + private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser(); + private String scope = DEFAULT_SCOPE; + private ClientTlsConfiguration tlsConfig; + private String tokenEndpoint; + + private Builder() { + } + + /** + * Opts into insecure {@code http} for the QuestDB {@code /settings} link (only meaningful via + * {@link #fromQuestDB}). It does not relax the identity provider endpoints configured here: + * the device authorization and token endpoints always require {@code https} unless they are + * loopback, so the device code and refresh token never cross the network in cleartext (matching + * the Python client). Defaults to {@code false}. + */ + public Builder allowInsecureTransport(boolean allowInsecureTransport) { + this.allowInsecureTransport = allowInsecureTransport; + return this; + } + + /** + * Sets the {@code audience} (or {@code resource}) request parameter, sent on the device + * authorization and refresh requests. Some identity providers require it so the issued token + * carries the {@code aud} claim QuestDB expects. {@link #fromQuestDB} discovers it from + * {@code acl.oidc.audience}. Optional. + */ + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + public OidcDeviceAuth build() { + if (clientId == null || clientId.isEmpty()) { + throw new OidcAuthException("clientId is required"); + } + if (deviceAuthorizationEndpoint == null || deviceAuthorizationEndpoint.isEmpty()) { + throw new OidcAuthException("deviceAuthorizationEndpoint is required"); + } + if (tokenEndpoint == null || tokenEndpoint.isEmpty()) { + throw new OidcAuthException("tokenEndpoint is required"); + } + if (scope == null || scope.isEmpty()) { + scope = DEFAULT_SCOPE; + } + Endpoint deviceEndpoint = Endpoint.parse(deviceAuthorizationEndpoint); + Endpoint parsedTokenEndpoint = Endpoint.parse(tokenEndpoint); + Endpoint issuerEndpoint = issuer != null && !issuer.isEmpty() ? Endpoint.parse(issuer) : null; + requireSecureIdpEndpoint(deviceEndpoint, "device authorization endpoint", deviceAuthorizationEndpoint, allowInsecureTransport); + requireSecureIdpEndpoint(parsedTokenEndpoint, "token endpoint", tokenEndpoint, allowInsecureTransport); + // enforce the credential-endpoint co-location / issuer pin on every construction path, not just + // discovery, so the documented guarantee holds for the explicit builder too + validateEndpointOrigins(parsedTokenEndpoint, deviceEndpoint, issuerEndpoint); + ClientTlsConfiguration tls = tlsConfig != null ? tlsConfig : defaultTlsConfig(); + return new OidcDeviceAuth(this, tls); + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) { + this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint; + return this; + } + + /** + * Selects which token {@link #getToken()} returns. Set to {@code true} when the server has + * {@code acl.oidc.groups.encoded.in.token=true} (the id token is returned), {@code false} + * otherwise (the access token is returned). Defaults to {@code false}. + */ + public Builder groupsInToken(boolean groupsInToken) { + this.groupsInToken = groupsInToken; + return this; + } + + public Builder httpTimeoutMillis(int httpTimeoutMillis) { + if (httpTimeoutMillis <= 0) { + throw new OidcAuthException("httpTimeoutMillis must be positive"); + } + this.httpTimeoutMillis = httpTimeoutMillis; + return this; + } + + /** + * Pins the identity provider by its {@code issuer} origin (for example + * {@code https://idp.example.com}). When set, {@link #build()} rejects the explicitly configured token + * or device authorization endpoint if it is not on this origin - a sanity check that the endpoints you + * supplied belong to the issuer you intended. A provider hosting its endpoints on a different origin + * than its issuer (for example Google) is rejected when pinned this way; for such a provider, configure + * the endpoints without an issuer. Optional. + *

+ * {@link #fromQuestDB(String, DiscoveryOptions)} pins differently: it constrains only the endpoints the + * untrusted {@code /settings} response advertised (to the issuer's origin, and under its path when the + * issuer has one), while endpoints discovered from the provider's own {@code .well-known} are trusted + * wherever the issuer hosts them - so discovery against an off-origin provider like Google works. + */ + public Builder issuer(String issuer) { + this.issuer = issuer; + return this; + } + + /** + * Sets how the device code challenge is shown to the user. Defaults to + * {@link DeviceCodePrompt#openBrowser()} - prints to {@code System.out} and also opens the + * verification URL in a browser when one is available; pass {@link DeviceCodePrompt#SYSTEM_OUT} + * to print only. + */ + public Builder prompt(DeviceCodePrompt prompt) { + this.prompt = prompt != null ? prompt : DeviceCodePrompt.openBrowser(); + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public Builder tlsConfig(ClientTlsConfiguration tlsConfig) { + this.tlsConfig = tlsConfig; + return this; + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + } + + /** + * Options for {@link #fromQuestDB(String, DiscoveryOptions)}: how to pin the identity provider + * (issuer), the TLS configuration for discovery and sign-in, whether to permit insecure {@code http}, + * and how to show the device code challenge. Every option is optional; an instance with nothing set + * behaves like {@link #fromQuestDB(String)}. + */ + public static final class DiscoveryOptions { + private boolean allowInsecureTransport; + private String issuer; + private DeviceCodePrompt prompt = DeviceCodePrompt.openBrowser(); + private ClientTlsConfiguration tlsConfig; + + /** + * Permits insecure {@code http} for the QuestDB server link only (the {@code /settings} discovery + * request). It does not relax the identity provider endpoints, which always require + * {@code https} unless they are loopback, so the device code and refresh token are never sent in + * cleartext. Enable only for local development on a trusted network. Defaults to {@code false}. + */ + public DiscoveryOptions allowInsecureTransport(boolean allowInsecureTransport) { + this.allowInsecureTransport = allowInsecureTransport; + return this; + } + + /** + * Pins the identity provider by its {@code issuer} origin (for example + * {@code https://idp.example.com}). It plays two roles: when the server does not advertise the + * device authorization endpoint, it is discovered from the issuer's + * {@code .well-known/openid-configuration} (the discovery origin comes only from this out-of-band + * issuer, never from {@code /settings}); and it constrains the endpoints the untrusted + * {@code /settings} response advertised - they must be on the issuer's origin, and under its path when + * the issuer has one, so a tampered {@code /settings} cannot redirect credentials to a different origin + * or to a different tenant on a path-based provider (for example a Keycloak realm path like + * {@code /realms/acme}). Endpoints discovered from the provider's own {@code .well-known} are trusted + * wherever the issuer hosts them, so an identity provider that serves its endpoints from a different + * origin than its issuer (for example Google) works through discovery. Optional. + */ + public DiscoveryOptions issuer(String issuer) { + this.issuer = issuer; + return this; + } + + /** + * Sets how the device code challenge is shown to the user. Defaults to + * {@link DeviceCodePrompt#openBrowser()} - prints to {@code System.out} and also opens the + * verification URL in a browser when one is available; pass {@link DeviceCodePrompt#SYSTEM_OUT} + * to print only. + */ + public DiscoveryOptions prompt(DeviceCodePrompt prompt) { + this.prompt = prompt != null ? prompt : DeviceCodePrompt.openBrowser(); + return this; + } + + /** + * Sets the TLS configuration used for the {@code /settings} discovery request, any identity + * provider discovery document, and the later sign-in requests. Defaults to full validation. + */ + public DiscoveryOptions tlsConfig(ClientTlsConfiguration tlsConfig) { + this.tlsConfig = tlsConfig; + return this; + } + } + + 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: + putNonNull(deviceCode, tag); + break; + case FIELD_USER_CODE: + putNonNull(userCode, tag); + break; + case FIELD_VERIFICATION_URI: + putNonNull(verificationUri, tag); + break; + case FIELD_VERIFICATION_URI_COMPLETE: + putNonNull(verificationUriComplete, tag); + break; + case FIELD_EXPIRES_IN: + expiresIn = parseIntOrZero(tag); + break; + case FIELD_INTERVAL: + interval = parseIntOrZero(tag); + break; + case FIELD_ERROR: + putNonNull(error, tag); + break; + case FIELD_ERROR_DESCRIPTION: + putNonNull(errorDescription, tag); + break; + default: + break; + } + } + field = FIELD_NONE; + break; + default: + break; + } + } + } + + private static final class Endpoint { + final String host; + final boolean isTls; + final String path; + final int port; + + private Endpoint(String host, int port, String path, boolean isTls) { + this.host = host; + this.port = port; + this.path = path; + this.isTls = isTls; + } + + static Endpoint parse(String url) { + if (url == null) { + throw new OidcAuthException("url is required"); + } + // Reject control characters, whitespace and display-unsafe code points anywhere in the url + // before it is split or used. A smuggled CR/LF (or other control char) in the host corrupts the + // outbound Host header; in the path or query it injects into the HTTP request line (postForm + // sends the path verbatim via .url(endpoint.path)) - a request-smuggling / header-injection + // vector when the url comes from a tampered /settings or discovery document. A bidi, zero-width + // or other format char (isUnsafeForDisplay, scanned per code point so a supplementary-plane one + // is not missed) reorders, hides or forges text when the url is echoed into a log line or the + // parse errors below. Rejecting up front keeps the raw url safe on the wire and on screen. + for (int i = 0, n = url.length(); i < n; ) { + final int cp = url.codePointAt(i); + if (cp <= ' ' || OidcAuthException.isUnsafeForDisplay(cp)) { + throw new OidcAuthException().put("invalid url, it contains an illegal character [url=").put(sanitizeForDisplay(url)).put(']'); + } + i += Character.charCount(cp); + } + 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; + // the authority ([userinfo@]host[:port]) ends at the first '/', '?' or '#'; splitting only on + // '/' (as before) folded a query/fragment - or userinfo - into the host on a path-less url + int authorityEnd = url.length(); + for (int i = hostStart, n = url.length(); i < n; i++) { + char c = url.charAt(i); + if (c == '/' || c == '?' || c == '#') { + authorityEnd = i; + break; + } + } + String hostPort = url.substring(hostStart, authorityEnd); + // a path-less url uses '/'; a query/fragment with no path is prefixed with '/' so the request + // line stays well-formed (a '/'-terminated authority already carries its own leading slash) + String path; + if (authorityEnd == url.length()) { + path = "/"; + } else if (url.charAt(authorityEnd) == '/') { + path = url.substring(authorityEnd); + } else { + path = "/" + url.substring(authorityEnd); + } + if (hostPort.indexOf('@') >= 0) { + // userinfo (user[:pass]@host) is unsupported: the HTTP layer would connect to the literal + // "user@host". Reject it clearly rather than mis-resolve it or surface a misleading port error + throw new OidcAuthException().put("invalid url, userinfo (user@host) is not supported [url=").put(url).put(']'); + } + if (hostPort.startsWith("[")) { + // bracketed IPv6 literal: the client's HTTP layer does not bracket the Host header, so + // reject it clearly rather than mis-parse it on a ':' inside the address + 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(']'); + } + if (port < 1 || port > 65535) { + throw new OidcAuthException().put("invalid url, the port must be between 1 and 65535 [url=").put(url).put(']'); + } + } else { + host = hostPort; + port = isTls ? 443 : 80; + } + if (host.isEmpty()) { + throw new OidcAuthException().put("invalid url, the host is empty [url=").put(url).put(']'); + } + return new Endpoint(host, port, path, isTls); + } + } + + private static final class SettingsDiscoveryParser implements JsonParser { + private static final int FIELD_AUDIENCE = 7; + private static final int FIELD_CLIENT_ID = 2; + private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 5; + private static final int FIELD_ENABLED = 1; + private static final int FIELD_GROUPS_IN_TOKEN = 6; + private static final int FIELD_NONE = 0; + private static final int FIELD_SCOPE = 3; + private static final int FIELD_TOKEN_ENDPOINT = 4; + final StringSink audience = new StringSink(); + final StringSink clientId = new StringSink(); + final StringSink deviceAuthorizationEndpoint = new StringSink(); + final StringSink scope = new StringSink(); + final StringSink tokenEndpoint = new StringSink(); + boolean groupsInToken; + boolean isOidcEnabled; + 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 if (Chars.equals("acl.oidc.audience", tag)) { + field = FIELD_AUDIENCE; + } 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; + case FIELD_AUDIENCE: + putNonNull(audience, tag); + break; + default: + break; + } + } + field = FIELD_NONE; + break; + default: + break; + } + } + } + + private static final class TokenResponseParser implements JsonParser, Mutable { + private static final int FIELD_ACCESS_TOKEN = 1; + private static final int FIELD_ERROR = 6; + private static final int FIELD_ERROR_DESCRIPTION = 7; + private static final int FIELD_EXPIRES_IN = 4; + private static final int FIELD_ID_TOKEN = 2; + private static final int FIELD_NONE = 0; + private static final int FIELD_REFRESH_TOKEN = 3; + final StringSink accessToken = new StringSink(); + final StringSink error = new StringSink(); + final StringSink errorDescription = new StringSink(); + final StringSink idToken = new StringSink(); + final StringSink refreshToken = new StringSink(); + int expiresIn; + 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: + putNonNull(accessToken, tag); + break; + case FIELD_ID_TOKEN: + putNonNull(idToken, tag); + break; + case FIELD_REFRESH_TOKEN: + putNonNull(refreshToken, tag); + break; + case FIELD_EXPIRES_IN: + expiresIn = parseIntOrZero(tag); + break; + case FIELD_ERROR: + putNonNull(error, tag); + break; + case FIELD_ERROR_DESCRIPTION: + putNonNull(errorDescription, tag); + break; + default: + break; + } + } + field = FIELD_NONE; + break; + default: + break; + } + } + } + + private static final class WellKnownDiscoveryParser implements JsonParser { + private static final int FIELD_DEVICE_AUTHORIZATION_ENDPOINT = 1; + private static final int FIELD_NONE = 0; + private static final int FIELD_TOKEN_ENDPOINT = 2; + final StringSink deviceAuthorizationEndpoint = new StringSink(); + final StringSink tokenEndpoint = new StringSink(); + 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 OIDC discovery document is a flat top-level object; only read top-level keys so a + // nested value cannot be mistaken for an endpoint + if (depth == 1) { + if (Chars.equals("device_authorization_endpoint", tag)) { + field = FIELD_DEVICE_AUTHORIZATION_ENDPOINT; + } else if (Chars.equals("token_endpoint", tag)) { + field = FIELD_TOKEN_ENDPOINT; + } 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; + default: + break; + } + } + field = FIELD_NONE; + break; + default: + break; + } + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java index ee999559..9e5543c5 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java @@ -91,10 +91,24 @@ public long lo() { } public Fragment recv(int timeout) { + // A positive timeout bounds the whole call, not each socket read. This loop re-reads while a + // chunk-size line (or the chunk-data-end CRLF) is incomplete, so without one shared deadline a server + // dribbling those bytes - one per timeout window - would run a single recv() for (line length) x + // timeout and defeat a caller's wall-clock bound (e.g. OidcDeviceAuth.parseBody). A non-positive + // timeout keeps the legacy "no bound" behaviour. + final boolean bounded = timeout > 0; + final long startNanos = bounded ? System.nanoTime() : 0L; while (true) { if (receive || dataLo == dataHi) { compactBuffer(); - dataHi += recvOrDie(dataHi, bufHi, timeout); + int callTimeout = timeout; + if (bounded) { + callTimeout = timeout - (int) ((System.nanoTime() - startNanos) / 1_000_000L); + if (callTimeout <= 0) { + throw new HttpClientException("timed out reading the chunked response body"); + } + } + dataHi += recvOrDie(dataHi, bufHi, callTimeout); } long p; // moving data pointer for scanning buffer switch (state) { diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java index b3b521da..b234d2a2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/AbstractResponse.java @@ -65,9 +65,24 @@ public Fragment recv(int timeout) { if (receive) { dataLo = bufLo; dataHi = bufLo; + // A positive timeout bounds the whole call, not each socket read. recvOrDie can return 0 + // without consuming the full timeout - e.g. an incomplete TLS record that decrypts to no + // application bytes - so without one shared deadline a server dribbling such reads would + // re-arm the full timeout on every iteration and keep this recv() running without bound, + // defeating a caller's wall-clock bound (e.g. OidcDeviceAuth.parseBody). A non-positive + // timeout keeps the legacy "no bound" behaviour. + final boolean bounded = timeout > 0; + final long startNanos = bounded ? System.nanoTime() : 0L; int len = 0; while (len == 0) { - len = recvOrDie(dataHi, bufHi, timeout); + int callTimeout = timeout; + if (bounded) { + callTimeout = timeout - (int) ((System.nanoTime() - startNanos) / 1_000_000L); + if (callTimeout <= 0) { + throw new HttpClientException("timed out reading the response body"); + } + } + len = recvOrDie(dataHi, bufHi, callTimeout); } dataHi += len; } diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java b/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java index 166a7a28..02c30505 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/Response.java @@ -34,4 +34,14 @@ public interface Response { * @return the received fragment */ Fragment recv(); + + /** + * Receives the next fragment of response data. A positive {@code timeout} bounds the whole call to that + * many milliseconds in total (not per socket read), so a server dribbling the body one byte at a time + * cannot keep a single call running past it; a non-positive {@code timeout} disables the bound. + * + * @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..0c4b0c4c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java +++ b/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java @@ -55,10 +55,12 @@ public class JsonLexer implements Mutable, Closeable { private final int cacheSizeLimit; private final IntStack objDepthStack = new IntStack(64); private final StringSink sink = new StringSink(); + private final StringSink unescapeSink = new StringSink(); private int arrayDepth = 0; private long cache; private int cacheCapacity; private int cacheSize = 0; + private boolean hasEscape = false; private boolean ignoreNext = false; private int objDepth = 0; private int position = 0; @@ -85,6 +87,7 @@ public void clear() { arrayDepth = 0; ignoreNext = false; quoted = false; + hasEscape = false; cacheSize = 0; useCache = false; position = 0; @@ -109,6 +112,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { int state = this.state; boolean quoted = this.quoted; boolean ignoreNext = this.ignoreNext; + boolean hasEscape = this.hasEscape; boolean useCache = this.useCache; int objDepth = this.objDepth; int arrayDepth = this.arrayDepth; @@ -125,6 +129,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { if (quoted) { if (c == '\\') { ignoreNext = true; + hasEscape = true; continue; } @@ -137,10 +142,10 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { int vp = (int) (posAtStart + valueStart - lo + 1 - cacheSize); if (state == S_EXPECT_NAME || state == S_EXPECT_FIRST_NAME) { - listener.onEvent(EVT_NAME, getCharSequence(valueStart, p, vp), vp); + listener.onEvent(EVT_NAME, getCharSequence(valueStart, p, vp, hasEscape), vp); state = S_EXPECT_COLON; } else { - listener.onEvent(arrayDepth > 0 ? EVT_ARRAY_VALUE : EVT_VALUE, getCharSequence(valueStart, p, vp), vp); + listener.onEvent(arrayDepth > 0 ? EVT_ARRAY_VALUE : EVT_VALUE, getCharSequence(valueStart, p, vp, hasEscape), vp); state = S_EXPECT_COMMA; } @@ -240,6 +245,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { } valueStart = p; quoted = true; + hasEscape = false; break; default: if (state != S_EXPECT_VALUE) { @@ -248,6 +254,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { // this isn't a quote, include this character valueStart = p - 1; quoted = false; + hasEscape = false; break; } } @@ -257,6 +264,7 @@ public void parse(long lo, long hi, JsonParser listener) throws JsonException { this.state = state; this.quoted = quoted; this.ignoreNext = ignoreNext; + this.hasEscape = hasEscape; this.objDepth = objDepth; this.arrayDepth = arrayDepth; @@ -286,6 +294,21 @@ private static boolean isNotATerminator(char c) { return unquotedTerminators.excludes(c); } + private static int parseHex4(CharSequence value, int offset) { + int result = 0; + for (int j = 0; j < 4; j++) { + final char c = value.charAt(offset + j); + // shared hex table lookup (-1 for non-hex), cheaper than Character.digit; the table is + // ASCII-sized, so a code point above 127 is never a hex digit + final int digit = c < 128 ? Numbers.hexNumbers[c] : -1; + if (digit < 0) { + return -1; + } + result = (result << 4) | digit; + } + return result; + } + private static JsonException unsupportedEncoding(int position) { return JsonException.$(position, "Unsupported encoding"); } @@ -319,7 +342,7 @@ private void extendCache(int n) throws JsonException { cache = ptr; } - private CharSequence getCharSequence(long lo, long hi, int position) throws JsonException { + private CharSequence getCharSequence(long lo, long hi, int position, boolean hasEscape) throws JsonException { sink.clear(); if (cacheSize == 0) { if (!Utf8s.utf8ToUtf16(lo, hi - 1, sink)) { @@ -328,7 +351,77 @@ private CharSequence getCharSequence(long lo, long hi, int position) throws Json } else { utf8DecodeCacheAndBuffer(lo, hi - 1, position); } - return sink; + // the decode above assembled the raw bytes verbatim; resolve JSON escapes only when the scan saw a + // backslash, so escape-free values and names skip unescape() and return the assembled sink directly. + return hasEscape ? unescape(sink) : sink; + } + + private CharSequence unescape(CharSequence raw) { + // called only when the scan saw a backslash, so at least one escape is present; walk the value once, + // copying plain chars and resolving each escape - no leading scan to re-find the first backslash. + final int n = raw.length(); + unescapeSink.clear(); + int i = 0; + while (i < n) { + char c = raw.charAt(i); + if (c != '\\' || i + 1 >= n) { + unescapeSink.put(c); + i++; + continue; + } + char esc = raw.charAt(i + 1); + switch (esc) { + case '"': + unescapeSink.put('"'); + i += 2; + break; + case '\\': + unescapeSink.put('\\'); + i += 2; + break; + case '/': + unescapeSink.put('/'); + i += 2; + break; + case 'b': + unescapeSink.put('\b'); + i += 2; + break; + case 'f': + unescapeSink.put('\f'); + i += 2; + break; + case 'n': + unescapeSink.put('\n'); + i += 2; + break; + case 'r': + unescapeSink.put('\r'); + i += 2; + break; + case 't': + unescapeSink.put('\t'); + i += 2; + break; + case 'u': + int cp = i + 6 <= n ? parseHex4(raw, i + 2) : -1; + if (cp >= 0) { + unescapeSink.put((char) cp); + i += 6; + } else { + // malformed unicode escape: 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..188ba9ec 100644 --- a/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java @@ -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,8 @@ public abstract class AbstractLineHttpSender implements Sender { private boolean closed; private int currentAddressIndex; private long flushAfterNanos = Long.MAX_VALUE; + private HttpTokenProvider httpTokenProvider; + private boolean isTokenPending; private JsonErrorParser jsonErrorParser; private boolean lastFlushFailed; private long pendingRows; @@ -225,7 +228,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 +248,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()); @@ -329,14 +334,17 @@ public static AbstractLineHttpSender createLineSender( if (protocolVersion == PROTOCOL_VERSION_NOT_SET_EXPLICIT) { Misc.free(cli); if (lastErrorSink != null) { - throw new LineSenderException("Failed to detect server line protocol version: " + lastErrorSink); + // sanitize the raw server body before it reaches the exception message (and any log/terminal): + // a hostile or proxied endpoint must not splice control, ANSI or bidi chars into the render + throw new LineSenderException("Failed to detect server line protocol version: ").putAsPrintable(lastErrorSink); } throw new LineSenderException("Failed to detect server line protocol version"); } + final AbstractLineHttpSender sender; switch (protocolVersion) { case PROTOCOL_VERSION_V1: - return new LineHttpSenderV1( + sender = new LineHttpSenderV1( hosts, ports, path, @@ -355,8 +363,9 @@ public static AbstractLineHttpSender createLineSender( currentAddressIndex, rnd ); + break; case PROTOCOL_VERSION_V2: - return new LineHttpSenderV2( + sender = new LineHttpSenderV2( hosts, ports, path, @@ -375,8 +384,9 @@ public static AbstractLineHttpSender createLineSender( currentAddressIndex, rnd ); + break; case PROTOCOL_VERSION_V3: - return new LineHttpSenderV3( + sender = new LineHttpSenderV3( hosts, ports, path, @@ -395,9 +405,19 @@ public static AbstractLineHttpSender createLineSender( currentAddressIndex, rnd ); + break; default: throw new LineSenderException("Unsupported protocol version: " + protocolVersion); } + if (httpTokenProvider != null) { + // The constructor already built the initial request without a token. Defer the first + // getToken() off this build path to the first row (table()), so a provider that signs in + // lazily - e.g. OidcDeviceAuth::getToken - can be wired before sign-in completes, + // and the token pull stays on the use/flush path the provider documents. + sender.httpTokenProvider = httpTokenProvider; + sender.isTokenPending = true; + } + return sender; } public static boolean isNotFound(DirectUtf8Sequence statusCode) { @@ -483,6 +503,9 @@ public Sender longColumn(CharSequence name, long value) { @TestOnly public void putRawMessage(Utf8Sequence msg) { + // stamp the deferred provider token (like table() does) so a raw message sent as the first row + // carries it; a no-op when no provider is configured + stampTokenIfPending(); request.put(msg); // message must include trailing \n state = RequestState.EMPTY; if (rowAdded()) { @@ -539,6 +562,9 @@ public Sender table(CharSequence table) { if (table.length() == 0) { throw new LineSenderException("table name cannot be empty"); } + // stamp the deferred provider token before the first row of this request, so the send carries it; + // a no-op once the token has been stamped or when no provider is configured + stampTokenIfPending(); // set bookmark at start of the line. rowBookmark = request.getContentLength(); state = RequestState.TABLE_NAME_SET; @@ -727,12 +753,33 @@ 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) .header("User-Agent", "QuestDB/java/" + questDBVersion); if (username != null) { r.authBasic(username, password); + } else if (httpTokenProvider != null) { + if (pullProviderToken) { + // pull a fresh token per request so a long-lived sender follows token refreshes; + // validateToken rejects a null/empty/blank return, or a token carrying a control or + // non-ASCII char (both forbidden by the HttpTokenProvider contract), rather than splice a + // malformed or CR/LF-injected "Authorization: Bearer " header onto the wire + CharSequence token = httpTokenProvider.getToken(); + HttpTokenProvider.validateToken(token); + r.authToken(token); + } else { + // do NOT pull the token on the construct/flush path: getToken() can throw (not signed in + // yet, or a failed silent refresh). Here - after client.newRequest() reset and re-headered + // the shared request but before withContent() - a throw would leave a half-built request + // and corrupt the sender, turning an already-successful flush into an exception. Defer to + // the first row (stampTokenIfPending), where a failed pull is retriable and rebuilds cleanly. + isTokenPending = true; + } } else if (authToken != null) { r.authToken(authToken); } @@ -766,6 +813,20 @@ private boolean rowAdded() { return pendingRows == autoFlushRows; } + private void stampTokenIfPending() { + if (isTokenPending) { + // The construct/flush path deferred the token so a lazily-signing-in provider (e.g. + // OidcDeviceAuth::getToken) could be wired before sign-in completed, and so a provider + // failure never strikes after a successful send. The caller is now starting the first row, so + // rebuild the still-empty request to carry the token before any row data goes in. Clear the + // flag only after newRequest(true) succeeds: a pull that throws (not signed in yet, or a failed + // refresh) leaves the stamp pending, so the next row re-runs this and fully rebuilds the + // request - the sender is never left corrupted. + request = newRequest(true); + isTokenPending = false; + } + } + private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient.ResponseHeaders response, boolean retryable) { CharSequence statusAscii = statusCode.asAsciiCharSequence(); if (Chars.equals("405", statusAscii)) { @@ -778,9 +839,11 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. chunkedResponseToSink(response, sink); LineSenderException ex = new LineSenderException("Could not flush buffer: HTTP endpoint authentication error", retryable); if (sink.length() > 0) { - ex = ex.put(": ").put(sink); + // sanitize the raw server body before it reaches the exception message (and any log/terminal): + // an untrusted or proxied endpoint must not splice control, ANSI or bidi chars into the render + ex = ex.put(": ").putAsPrintable(sink); } - ex.put(" [http-status=").put(statusAscii).put(']'); + ex.put(" [http-status=").putAsPrintable(statusAscii).put(']'); client.disconnect(); throw ex; } @@ -796,11 +859,14 @@ private void throwOnHttpErrorResponse(DirectUtf8Sequence statusCode, HttpClient. } // ok, no JSON, let's do something more generic sink.clear(); - sink.put("Could not flush buffer: "); chunkedResponseToSink(response, sink); - sink.put(" [http-status=").put(statusCode).put(']'); + // sanitize the raw server body before it reaches the exception message (and any log/terminal): + // an untrusted or proxied endpoint must not splice control, ANSI or bidi chars into the render + LineSenderException ex = new LineSenderException("Could not flush buffer: ", retryable) + .putAsPrintable(sink) + .put(" [http-status=").putAsPrintable(statusCode.asAsciiCharSequence()).put(']'); client.disconnect(); - throw new LineSenderException(sink, retryable); + throw ex; } private void validateNotClosed() { @@ -968,16 +1034,16 @@ public void onEvent(int code, CharSequence tag, int position) throws JsonExcepti private void drainAndReset(LineSenderException sink, DirectUtf8Sequence httpStatus) { assert state == State.INIT; - sink.put(messageSink).put(" [http-status=").put(httpStatus.asAsciiCharSequence()); + sink.putAsPrintable(messageSink).put(" [http-status=").putAsPrintable(httpStatus.asAsciiCharSequence()); if (codeSink.length() != 0 || errorIdSink.length() != 0 || lineSink.length() != 0) { if (errorIdSink.length() != 0) { - sink.put(", id: ").put(errorIdSink); + sink.put(", id: ").putAsPrintable(errorIdSink); } if (codeSink.length() != 0) { - sink.put(", code: ").put(codeSink); + sink.put(", code: ").putAsPrintable(codeSink); } if (lineSink.length() != 0) { - sink.put(", line: ").put(lineSink); + sink.put(", line: ").putAsPrintable(lineSink); } } sink.put(']'); @@ -1008,7 +1074,9 @@ LineSenderException toException(Response chunkedRsp, DirectUtf8Sequence httpStat while ((fragment = chunkedRsp.recv()) != null) { jsonSink.putNonAscii(fragment.lo(), fragment.hi()); } - exception.put(jsonSink).put(" [http-status=").put(httpStatus.asAsciiCharSequence()).put(']'); + // sanitize the raw server body before it reaches the exception message (and any log/terminal): + // an untrusted or proxied endpoint must not splice control, ANSI or bidi chars into the render + exception.putAsPrintable(jsonSink).put(" [http-status=").putAsPrintable(httpStatus.asAsciiCharSequence()).put(']'); reset(); return exception; } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 1706401e..fac498e7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -25,6 +25,7 @@ package io.questdb.client.cutlass.qwp.client; import io.questdb.client.ClientTlsConfiguration; +import io.questdb.client.HttpTokenProvider; import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClientFactory; @@ -285,6 +286,11 @@ public class QwpQueryClient implements QuietCloseable { private boolean tlsEnabled; // Only meaningful when tlsEnabled. Default is full validation against the JVM's trust store. private int tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL; + // Supplies a fresh Bearer token at each WebSocket upgrade (the initial + // connect and every failover reconnect), so a long-lived client follows + // token rotation. Mutually exclusive with the fixed authorizationHeader + // synthesized by withBearerToken/withBasicAuth; null when unset. + private HttpTokenProvider tokenProvider; private char[] trustStorePassword; private String trustStorePath; private volatile WebSocketClient webSocketClient; @@ -678,6 +684,11 @@ public void close() { * observed so callers can distinguish "no primary available" from "all * endpoints unreachable" (the latter surfaces as a plain * {@link HttpClientException}). + *

+ * A configured token provider is queried once here, before the walk. A + * provider failure (not signed in, a failed silent refresh, a rejected + * token) is cluster-wide, so it fails fast with the provider's own error + * rather than being retried across endpoints as a transport failure. */ public synchronized void connect() { if (closedFlag.get()) { @@ -696,6 +707,12 @@ public synchronized void connect() { QwpServerInfo lastObservedMismatch = null; QwpIngressRoleRejectedException lastUpgradeRoleReject = null; Throwable lastTransportError = null; + // Resolve the bearer credential once, before the endpoint walk: a token is cluster-wide, so a + // token-provider failure (not signed in, a failed silent refresh, a rejected token) is not a + // per-endpoint transport fault. Resolving here lets it propagate as the provider's own error + // instead of being folded into "all endpoints unreachable", and avoids re-querying the provider + // once per endpoint. + String authHeader = resolveAuthorizationHeader(); while (true) { int i = hostTracker.pickNext(); if (i < 0) { @@ -703,7 +720,7 @@ public synchronized void connect() { } Endpoint ep = endpoints.get(i); try { - connectToEndpoint(ep); + connectToEndpoint(ep, authHeader); } catch (QwpAuthFailedException ae) { cleanupFailedConnect(); throw ae; @@ -838,11 +855,12 @@ public int getCompressionLevelForTest() { /** * Test-only hook: the synthesized {@code Authorization} header value * ({@code Basic ...} or {@code Bearer ...}), or null when no credentials - * were configured. + * were configured. When a token provider is configured, queries it and + * validates the returned token, exactly as a real upgrade would. */ @TestOnly public String getAuthorizationHeaderForTest() { - return authorizationHeader; + return resolveAuthorizationHeader(); } /** @@ -1003,6 +1021,9 @@ public QwpQueryClient withAuthTimeout(long authTimeoutMs) { */ public QwpQueryClient withBasicAuth(String username, String password) { checkPreConnect("withBasicAuth"); + if (tokenProvider != null) { + throw new IllegalStateException("withBasicAuth cannot be combined with withBearerTokenProvider"); + } if (username == null || password == null) { throw new IllegalArgumentException("username and password must not be null"); } @@ -1020,6 +1041,9 @@ public QwpQueryClient withBasicAuth(String username, String password) { */ public QwpQueryClient withBearerToken(String token) { checkPreConnect("withBearerToken"); + if (tokenProvider != null) { + throw new IllegalStateException("withBearerToken cannot be combined with withBearerTokenProvider"); + } if (token == null) { throw new IllegalArgumentException("token must not be null"); } @@ -1027,6 +1051,36 @@ public QwpQueryClient withBearerToken(String token) { return this; } + /** + * Configures HTTP Bearer authentication with a token supplied on demand by + * {@code provider}, instead of the fixed token captured once by + * {@link #withBearerToken(String)}. The provider is queried for a fresh + * token at every WebSocket upgrade -- the initial {@link #connect()} and + * each failover reconnect -- so a long-lived client keeps working as the + * token rotates (for example an OIDC device-flow token: + * {@code .withBearerTokenProvider(auth::getToken)}). + *

+ * {@link HttpTokenProvider#getToken()} runs on the connect and reconnect + * paths, so it must return promptly and must not block on interactive + * input; a quick silent refresh is fine. Each returned token is validated + * ({@link HttpTokenProvider#validateToken(CharSequence)}) before it is sent, + * and a provider that throws fails that connection attempt. Mutually + * exclusive with {@link #withBearerToken(String)} and + * {@link #withBasicAuth(String, String)}. Must be called before + * {@link #connect}. + */ + public QwpQueryClient withBearerTokenProvider(HttpTokenProvider provider) { + checkPreConnect("withBearerTokenProvider"); + if (provider == null) { + throw new IllegalArgumentException("provider must not be null"); + } + if (authorizationHeader != null) { + throw new IllegalStateException("withBearerTokenProvider cannot be combined with withBearerToken or withBasicAuth"); + } + this.tokenProvider = provider; + return this; + } + /** * Overrides the default I/O buffer pool depth (4). Larger pools let the * I/O thread decode further ahead of the consumer at the cost of memory; @@ -1358,7 +1412,7 @@ private void cleanupFailedConnect() { currentEndpointIndex = -1; } - private void connectToEndpoint(Endpoint ep) { + private void connectToEndpoint(Endpoint ep, String authHeader) { if (tlsEnabled) { webSocketClient = WebSocketClientFactory.newTlsInstance( new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode)); @@ -1369,7 +1423,7 @@ private void connectToEndpoint(Endpoint ep) { webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId()); webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader()); webSocketClient.setQwpMaxBatchRows(maxBatchRows); - runUpgradeWithTimeout(ep); + runUpgradeWithTimeout(ep, authHeader); negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); negotiatedZstdLevel = webSocketClient.getServerNegotiatedZstdLevel(); @@ -1687,6 +1741,10 @@ private void reconnectViaTracker() { QwpServerInfo lastMismatch = null; Throwable lastError = null; boolean retriedAfterReset = false; + // Resolve the bearer credential once per reconnect, before the endpoint walk, for the same + // reason as connect(): a provider failure is cluster-wide, so surface it directly rather than + // as a per-endpoint transport error retried across every host. + String authHeader = resolveAuthorizationHeader(); while (true) { int i = hostTracker.pickNext(); if (i < 0) { @@ -1699,7 +1757,7 @@ private void reconnectViaTracker() { } Endpoint ep = endpoints.get(i); try { - connectToEndpoint(ep); + connectToEndpoint(ep, authHeader); } catch (QwpAuthFailedException ae) { cleanupFailedConnect(); throw ae; @@ -1744,11 +1802,25 @@ private void reconnectViaTracker() { + ", lastError=" + (lastError == null ? "" : lastError.getMessage()) + ']'); } - private void runUpgradeWithTimeout(Endpoint ep) { + private String resolveAuthorizationHeader() { + // With a token provider, query it once per connect()/reconnect (the caller resolves before the + // endpoint walk) so a reconnect presents a freshly refreshed token; validateToken rejects a + // null/empty/blank return, or one carrying a control or non-ASCII character, before it reaches + // the "Bearer " header. A provider that throws (a failed silent refresh, or not signed in yet) + // propagates out of connect()/reconnect with its own message, matching the QWP ingress sender. + if (tokenProvider != null) { + CharSequence token = tokenProvider.getToken(); + HttpTokenProvider.validateToken(token); + return "Bearer " + token; + } + return authorizationHeader; + } + + private void runUpgradeWithTimeout(Endpoint ep, String authHeader) { int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); try { webSocketClient.connect(ep.host, ep.port); - webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authorizationHeader); + webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader); } catch (HttpClientException ex) { if (ex.isTimeout()) { HttpClientException timeout = new HttpClientException("WebSocket upgrade to ") diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 9b9cc45d..54656c3e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -72,6 +72,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; /** * QWP v1 WebSocket client sender for streaming data to QuestDB. @@ -135,7 +136,12 @@ public class QwpWebSocketSender implements Sender { // enough window to preserve the trailing category distribution. private static final int MIN_ERROR_INBOX_CAPACITY = 16; private static final String WRITE_PATH = "/write/v4"; - private final String authorizationHeader; + // Yields the Authorization header value presented on each WebSocket upgrade. A constant for a + // fixed token or Basic credential; for an httpTokenProvider it pulls a freshly refreshed token, + // so the initial connect and every reconnect re-handshake carry the current token. May be null + // when no auth is configured. Evaluated inside buildAndConnect's per-endpoint try, so a throwing + // provider (e.g. a failed silent refresh) is handled as a connect failure rather than escaping. + private final Supplier authorizationHeaderSupplier; private final int autoFlushBytes; private final long autoFlushIntervalNanos; // Auto-flush configuration @@ -280,14 +286,14 @@ private QwpWebSocketSender( int autoFlushRows, int autoFlushBytes, long autoFlushIntervalNanos, - String authorizationHeader + Supplier authorizationHeaderSupplier ) { if (endpoints == null || endpoints.isEmpty()) { throw new IllegalArgumentException("endpoints must be non-empty"); } this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints)); this.hostTracker = new QwpHostHealthTracker(this.endpoints.size()); - this.authorizationHeader = authorizationHeader; + this.authorizationHeaderSupplier = authorizationHeaderSupplier; this.tlsConfig = tlsConfig; this.encoder = new QwpWebSocketEncoder(DEFAULT_BUFFER_SIZE); this.tableBuffers = new CharSequenceObjHashMap<>(); @@ -571,7 +577,7 @@ public static QwpWebSocketSender connect( long authTimeoutMs ) { return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeader, + autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), requestDurableAck, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, @@ -590,7 +596,7 @@ public static QwpWebSocketSender connect( int autoFlushRows, int autoFlushBytes, long autoFlushIntervalNanos, - String authorizationHeader, + Supplier authorizationHeaderSupplier, boolean requestDurableAck, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, @@ -608,7 +614,7 @@ public static QwpWebSocketSender connect( QwpWebSocketSender sender = new QwpWebSocketSender( endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, - authorizationHeader + authorizationHeaderSupplier ); try { sender.requestDurableAck = requestDurableAck; @@ -658,7 +664,7 @@ public static QwpWebSocketSender createForTesting(String host, int port, String return new QwpWebSocketSender( singleEndpoint(host, port), null, DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS, - authorizationHeader + fixedAuthHeader(authorizationHeader) ); } @@ -2302,6 +2308,10 @@ private static Throwable captureCloseError(Throwable terminalError, Throwable t) return terminalError; } + private static Supplier fixedAuthHeader(String header) { + return header == null ? null : () -> header; + } + private static long maskGeoHashBits(long value, int precisionBits) { return precisionBits >= 64 ? value : value & ((1L << precisionBits) - 1L); } @@ -2441,7 +2451,12 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) { newClient.setQwpRequestDurableAck(requestDurableAck); newClient.connect(ep.host, ep.port); int upgradeTimeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); - newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authorizationHeader); + // Pull the current Authorization header for this handshake. For an httpTokenProvider + // this re-queries the provider, so a reconnect presents a freshly refreshed token. A + // provider that throws here (a failed silent refresh) is caught below as a connect + // failure for this endpoint and retried within the reconnect budget. + String authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get(); + newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authHeader); } catch (HttpClientException e) { HttpClientException classified = QwpUpgradeFailures.classify(newClient, ep.host, ep.port, e); newClient.close(); diff --git a/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java new file mode 100644 index 00000000..2577d165 --- /dev/null +++ b/core/src/main/java/io/questdb/client/std/str/DisplaySafe.java @@ -0,0 +1,94 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.std.str; + +import static io.questdb.client.std.Numbers.hexDigits; + +/** + * Shared classifier for whether a code point is safe to show in a terminal or a log line. It is the one + * source of truth for the client's display-escaping: {@link Utf16Sink#putAsPrintable(CharSequence)} escapes + * everything it rejects, and the OIDC auth layer strips it from untrusted identity-provider text. Left raw, + * attacker-influenced text - an ILP server's error body, a column name, a verification URL - could reorder, + * hide, or forge what a human reads (a right-to-left override, a zero-width joiner, an ANSI escape). + */ +public final class DisplaySafe { + + private DisplaySafe() { + } + + /** + * Returns {@code true} when {@code cp} can be shown verbatim, {@code false} when it must be escaped or + * stripped. A code point is unsafe if it is a control char (C0/C1, DEL), a Unicode format char (bidi + * embeddings/overrides/isolates, LRM/RLM marks, zero-width joiners, the BOM, supplementary-plane tag + * chars) or a surrogate (a lone half, with no displayable meaning). + */ + public static boolean isDisplaySafe(int cp) { + // Printable ASCII is the overwhelmingly common case and is never a control, format or surrogate char, + // so a single range check returns it without the Character.getType table lookup. + if (cp >= 0x20 && cp < 0x7f) { + return true; + } + if (Character.isISOControl(cp)) { + return false; + } + final int type = Character.getType(cp); + if (type == Character.FORMAT || type == Character.SURROGATE) { + return false; + } + // The explicit bidi/BOM set is redundant with the FORMAT category on a conformant JDK, but kept as + // belt-and-suspenders on one that categorizes these differently. Hex literals (not char escapes) keep + // this source ASCII, so it carries none of the chars it guards. + return !(cp >= 0x202A && cp <= 0x202E) // LRE, RLE, PDF, LRO, RLO + && !(cp >= 0x2066 && cp <= 0x2069) // LRI, RLI, FSI, PDI + && cp != 0x200E && cp != 0x200F // LRM, RLM + && cp != 0xFEFF; // BOM / zero-width no-break space + } + + /** + * The inverse of {@link #isDisplaySafe(int)}: {@code true} when {@code cp} must not reach a terminal or + * log line raw. + */ + public static boolean isUnsafeForDisplay(int cp) { + return !isDisplaySafe(cp); + } + + // Escapes a code point to one (BMP) or two (supplementary, as its surrogate pair) visible \\uXXXX + // sequences, so the escaped value still names the original char. Emitting all four hex digits keeps a + // char above U+00FF (e.g. U+202E) correct rather than truncated to its low byte. A static helper here + // (not a private method on Utf16Sink) keeps the source Java 8 - private interface methods are Java 9. + static void putUnicodeEscape(Utf16Sink sink, int cp) { + if (cp > 0xFFFF) { + putUnicodeEscape(sink, Character.highSurrogate(cp)); + putUnicodeEscape(sink, Character.lowSurrogate(cp)); + return; + } + sink.put('\\'); + sink.put('u'); + sink.put(hexDigits[(cp >> 12) & 0xF]); + sink.put(hexDigits[(cp >> 8) & 0xF]); + sink.put(hexDigits[(cp >> 4) & 0xF]); + sink.put(hexDigits[cp & 0xF]); + } +} diff --git a/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java b/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java index f53e1ae5..e824c692 100644 --- a/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java +++ b/core/src/main/java/io/questdb/client/std/str/Utf16Sink.java @@ -26,8 +26,6 @@ import org.jetbrains.annotations.Nullable; -import static io.questdb.client.std.Numbers.hexDigits; - /** * Family of sinks that write out character value as UTF16 encoded bytes. This interface * is separate from {@link CharSink} to achieve two goals: @@ -45,24 +43,33 @@ default Utf16Sink put(@Nullable Utf8Sequence us) { } default void putAsPrintable(CharSequence nonPrintable) { - for (int i = 0, n = nonPrintable.length(); i < n; i++) { - char c = nonPrintable.charAt(i); - putAsPrintable(c); + // Scan by code point, not UTF-16 unit. A supplementary-plane format char (e.g. a U+E00xx language + // tag char) arrives as a surrogate pair whose halves report SURROGATE rather than FORMAT, and a + // lone surrogate likewise - per-unit scanning would pass both through raw. Judging the whole code + // point (via DisplaySafe, the shared classifier) escapes them, while a normal supplementary char + // such as an emoji is neither control nor format and is emitted verbatim. + for (int i = 0, n = nonPrintable.length(); i < n; ) { + final int cp = Character.codePointAt(nonPrintable, i); + final int count = Character.charCount(cp); + if (DisplaySafe.isDisplaySafe(cp)) { + for (int j = 0; j < count; j++) { + put(nonPrintable.charAt(i + j)); + } + } else { + DisplaySafe.putUnicodeEscape(this, cp); + } + i += count; } } default void putAsPrintable(char c) { - if (c > 0x1F && c != 0x7F) { + // A single UTF-16 unit: escape control chars, Unicode format chars, and a lone surrogate (which has + // no displayable meaning). Supplementary-plane format chars are caught by the code-point-aware + // putAsPrintable(CharSequence). + if (DisplaySafe.isDisplaySafe(c)) { put(c); } else { - put('\\'); - put('u'); - - final int s = (int) c & 0xFF; - put('0'); - put('0'); - put(hexDigits[s / 0x10]); - put(hexDigits[s % 0x10]); + DisplaySafe.putUnicodeEscape(this, c); } } @@ -93,5 +100,4 @@ default Utf16Sink putNonAscii(long lo, long hi) { Utf8s.utf8ToUtf16(lo, hi, this); return this; } - -} \ No newline at end of file +} diff --git a/core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java new file mode 100644 index 00000000..bda404e1 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/HttpTokenProviderTest.java @@ -0,0 +1,82 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test; + +import io.questdb.client.HttpTokenProvider; +import io.questdb.client.cutlass.line.LineSenderException; +import org.junit.Assert; +import org.junit.Test; + +public class HttpTokenProviderTest { + + @Test + public void testValidateTokenAcceptsPrintableAscii() { + // a real bearer token is printable ASCII (base64url JWT segments joined by dots); validateToken + // must pass it through unchanged + HttpTokenProvider.validateToken("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJfc3NvIn0.abc-DEF_123"); + HttpTokenProvider.validateToken("a~b"); // 0x7e (~) is the top of the allowed range + HttpTokenProvider.validateToken("a b"); // an interior space (0x20) is allowed; only an all-blank token is rejected + } + + @Test + public void testValidateTokenNeverEchoesTheToken() { + // the token is the secret this guards; it must never appear in the exception message + try { + HttpTokenProvider.validateToken("SUPERSECRET" + (char) 0x0d + (char) 0x0a + "TOKEN"); + Assert.fail("expected the token to be rejected"); + } catch (LineSenderException e) { + Assert.assertFalse(e.getMessage(), e.getMessage().contains("SUPERSECRET")); + } + } + + @Test + public void testValidateTokenRejectsBlank() { + assertRejected(null, "null or empty token"); + assertRejected("", "null or empty token"); + assertRejected(" ", "null or empty token"); + } + + @Test + public void testValidateTokenRejectsControlOrNonAscii() { + // a control char would break out of the "Authorization: Bearer " header (CR/LF injects into + // the request line); a non-ASCII char is silently truncated to one byte by the ASCII header writer. + // The strings are built with explicit char values to keep this source pure ASCII. + assertRejected("abc" + (char) 0x0d + (char) 0x0a + "def", "control or non-ASCII character"); // CR/LF + assertRejected("tok" + (char) 0x00 + "en", "control or non-ASCII character"); // NUL + assertRejected((char) 0x1b + "[31mred", "control or non-ASCII character"); // ANSI escape (ESC) + assertRejected("a" + (char) 0x1f + "b", "control or non-ASCII character"); // 0x1f, just below the 0x20 lower bound + assertRejected("a" + (char) 0x7f + "b", "control or non-ASCII character"); // DEL (0x7f), just above the 0x7e upper bound + assertRejected("tok" + (char) 0xe9 + "n", "control or non-ASCII character"); // non-ASCII (e-acute, 0xe9) + } + + private static void assertRejected(CharSequence token, String expectedMessage) { + try { + HttpTokenProvider.validateToken(token); + Assert.fail("expected token to be rejected: " + token); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java b/core/src/test/java/io/questdb/client/test/SenderBuilderErrorApiTest.java index ed3c35c6..93ac401c 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,81 @@ 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 testHttpTokenProviderNullRejectedAndExclusiveWithLaterUsernamePassword() { + // a null provider is rejected up front + try { + Sender.builder(Sender.Transport.HTTP).address("localhost:9000").httpTokenProvider(null); + Assert.fail("expected a null provider to be rejected"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("token provider cannot be null")); + } + // the reverse of the mutual-exclusion case above: provider first, then username/password. This hits a + // distinct guard in httpUsernamePassword(), which the provider-then-token / token-then-provider / + // username-then-provider orderings above do not reach + try { + Sender.builder(Sender.Transport.HTTP).address("localhost:9000") + .httpTokenProvider(() -> "dynamic").httpUsernamePassword("u", "p"); + Assert.fail("expected token-provider-already-configured"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("token provider authentication is already configured")); + } + } + + @Test + public void testHttpTokenProviderAcceptedForWebSocket() { + // the provider is supported over WebSocket (queried at each upgrade handshake): it must pass + // build-time validation and fail only on the connection itself, never with a "not supported" + // rejection. 127.0.0.1:1 is refused promptly, and InitialConnectMode defaults to OFF (fail fast). + try (Sender ignored = Sender.builder(Sender.Transport.WEBSOCKET).address("127.0.0.1:1") + .httpTokenProvider(() -> "dynamic").build()) { + Assert.fail("expected a connection failure against a dead address"); + } catch (LineSenderException e) { + Assert.assertFalse(e.getMessage(), e.getMessage().contains("not supported for WebSocket")); + } + } + + @Test + public void testHttpTokenProviderRejectedForTcpAndUdp() { + // TCP uses challenge-response key auth and UDP has no auth; neither carries a bearer token, + // so both must reject the provider at build time + assertProviderRejected(Sender.Transport.TCP, "token provider authentication is not supported for TCP protocol"); + assertProviderRejected(Sender.Transport.UDP, "token provider authentication is not supported for UDP transport"); + } + + private static void assertProviderRejected(Sender.Transport transport, String expectedMessage) { + try (Sender ignored = Sender.builder(transport).address("localhost:9009") + .httpTokenProvider(() -> "dynamic").build()) { + Assert.fail("expected the token provider to be rejected for " + transport); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); + } + } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.java new file mode 100644 index 00000000..21d80da9 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/BrowserLauncherTest.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.auth; + +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.net.URI; + +public class BrowserLauncherTest { + + @Test + public void testAcceptsHttpAndHttps() throws Exception { + Assert.assertNotNull(invokeSafeHttpUri("https://idp.example.com/device?user_code=ABCD")); + Assert.assertNotNull(invokeSafeHttpUri("http://localhost:8080/device")); + // the scheme allowlist is case-insensitive + Assert.assertNotNull(invokeSafeHttpUri("HTTPS://idp.example.com")); + } + + @Test + public void testOpenIsBestEffortForRejectedUrls() throws Exception { + // these URLs are rejected by the scheme/parse allowlist, so open() returns at the safeHttpUri null + // check before touching java.awt.Desktop. Assert the rejection holds so the no-op below is provably + // the URL-rejection path (not an incidental headless no-op), then confirm open() tolerates each + // without throwing (and never launches a real browser, so the test is safe on a desktop machine too) + Assert.assertNull(invokeSafeHttpUri("javascript:alert(1)")); + Assert.assertNull(invokeSafeHttpUri("not a url")); + invokeOpen(null); + invokeOpen("javascript:alert(1)"); + invokeOpen("not a url"); + } + + @Test + public void testOpenRespectsDisableProperty() throws Exception { + // a VALID http(s) URL: if open() did not short-circuit on the kill-switch it would proceed toward + // java.awt.Desktop, so asserting safeHttpUri accepts it proves the no-op below is the kill-switch, + // not URL rejection. This gate is also what keeps the suite from launching a real browser on a + // developer machine. + String validUrl = "https://idp.example.com/device?user_code=ABCD"; + Assert.assertNotNull("the URL must be one open() would otherwise launch", invokeSafeHttpUri(validUrl)); + String prop = "questdb.client.oidc.open.browser"; + String prev = System.getProperty(prop); + System.setProperty(prop, "false"); + try { + invokeOpen(validUrl); // kill-switch off: must return without launching and without throwing + } finally { + if (prev == null) { + System.clearProperty(prop); + } else { + System.setProperty(prop, prev); + } + } + } + + @Test + public void testRejectsDangerousOrMalformedUrls() throws Exception { + // an attacker-influenced verification URI must not smuggle a non-http(s) scheme to the OS handler + Assert.assertNull(invokeSafeHttpUri("javascript:alert(1)")); + Assert.assertNull(invokeSafeHttpUri("data:text/html,")); + Assert.assertNull(invokeSafeHttpUri("file:///etc/passwd")); + Assert.assertNull(invokeSafeHttpUri("ftp://example.com/x")); + Assert.assertNull(invokeSafeHttpUri("not a url")); + Assert.assertNull(invokeSafeHttpUri("//idp.example.com/device")); + Assert.assertNull(invokeSafeHttpUri("")); + Assert.assertNull(invokeSafeHttpUri(null)); + } + + // BrowserLauncher is a package-private helper; the client is an open module, so reflection reaches its + // static methods without widening production visibility for the test (mirrors invokeIsLoopbackHost). + private static void invokeOpen(String url) throws Exception { + Method m = Class.forName("io.questdb.client.cutlass.auth.BrowserLauncher").getDeclaredMethod("open", String.class); + m.setAccessible(true); + m.invoke(null, url); + } + + private static URI invokeSafeHttpUri(String url) throws Exception { + Method m = Class.forName("io.questdb.client.cutlass.auth.BrowserLauncher").getDeclaredMethod("safeHttpUri", String.class); + m.setAccessible(true); + return (URI) m.invoke(null, url); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java new file mode 100644 index 00000000..40f532ce --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/MockOidcServer.java @@ -0,0 +1,374 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.auth; + +import io.questdb.client.std.str.StringSink; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * A minimal HTTP/1.1 server for tests that impersonates an OIDC identity provider (and, + * when needed, the QuestDB {@code /settings} endpoint). It speaks just enough HTTP to drive + * {@link io.questdb.client.cutlass.auth.OidcDeviceAuth}: it reads a request, hands the path + * and body to a {@link Handler}, and writes back a {@code Content-Length}-framed response on + * a keep-alive connection. + */ +public class MockOidcServer implements Closeable { + private final Thread acceptThread; + private final List connSockets = Collections.synchronizedList(new ArrayList<>()); + private final List connThreads = Collections.synchronizedList(new ArrayList<>()); + private final Handler handler; + private final List requestAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + private final ServerSocket serverSocket; + + public MockOidcServer(Handler handler) throws IOException { + this.handler = handler; + this.serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + this.acceptThread = new Thread(this::acceptLoop, "mock-oidc-accept"); + this.acceptThread.setDaemon(true); + this.acceptThread.start(); + } + + public static MockResponse chunkedJson(int status, String body) { + return new MockResponse(status, body, true); + } + + public static MockResponse dropConnection() { + // close the connection without responding, so the client sees a transport failure (connection + // reset / EOF) on this request - used to simulate an unreachable endpoint that is co-located with + // a working one on the same mock origin + MockResponse response = new MockResponse(0, "", false); + response.dropConnection = true; + return response; + } + + public static MockResponse json(int status, String body) { + return new MockResponse(status, body, false); + } + + public static MockResponse oversizedJson(long bodyBytes) { + // stream a chunked body larger than the client's response-size cap (MAX_RESPONSE_BODY_BYTES), so the + // bounded read aborts on the cap instead of letting a hostile or MITM'd server stream an endless body + // and wedge the thread. The payload is all whitespace, which the JSON lexer skips, so the byte cap is + // what trips - not a parse error, and not the lexer's per-value length limit + MockResponse response = new MockResponse(200, "", true); + response.oversizedBodyBytes = bodyBytes; + return response; + } + + public static MockResponse raw(String rawResponse) { + // write the supplied bytes verbatim as the whole HTTP response, so a test can craft a malformed + // status line (for example a status code carrying control bytes) that the int-typed status factories + // cannot express + MockResponse response = new MockResponse(0, "", false); + response.rawResponse = rawResponse; + return response; + } + + public static MockResponse stall() { + MockResponse response = new MockResponse(200, "", true); + response.stall = true; + return response; + } + + @Override + public void close() throws IOException { + // tear the server down deterministically so a test's threads are gone before its assertions (and + // assertMemoryLeak's native-memory check) run, instead of lingering as daemon threads that can + // perturb a later test: stop accepting, drop every connection (which unblocks a handler reading a + // socket), then interrupt and join the accept and connection threads (interrupt wakes a stalled + // handler that is sleeping on the response body) + serverSocket.close(); + synchronized (connSockets) { + for (Socket s : connSockets) { + try { + s.close(); + } catch (IOException ignore) { + // already closed + } + } + } + interruptAndJoin(acceptThread); + synchronized (connThreads) { + for (Thread t : connThreads) { + interruptAndJoin(t); + } + } + } + + public String httpUrl(String path) { + return "http://127.0.0.1:" + port() + path; + } + + public int port() { + return serverSocket.getLocalPort(); + } + + public List requestAuthHeaders() { + return requestAuthHeaders; + } + + private static void interruptAndJoin(Thread t) { + t.interrupt(); + try { + t.join(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static String readLine(InputStream in) throws IOException { + StringSink sb = new StringSink(); + boolean any = false; + int c; + while ((c = in.read()) != -1) { + any = true; + if (c == '\r') { + continue; + } + if (c == '\n') { + return sb.toString(); + } + sb.put((char) c); + } + return any ? sb.toString() : null; + } + + private static Request readRequest(InputStream in) throws IOException { + String requestLine = readLine(in); + if (requestLine == null || requestLine.isEmpty()) { + return null; + } + String[] parts = requestLine.split(" "); + String method = parts[0]; + String path = parts.length > 1 ? parts[1] : ""; + int contentLength = 0; + String authorization = null; + String line; + while ((line = readLine(in)) != null && !line.isEmpty()) { + int idx = line.indexOf(':'); + if (idx > 0) { + String name = line.substring(0, idx).trim(); + if ("content-length".equalsIgnoreCase(name)) { + contentLength = Integer.parseInt(line.substring(idx + 1).trim()); + } else if ("authorization".equalsIgnoreCase(name)) { + authorization = line.substring(idx + 1).trim(); + } + } + } + String body = ""; + if (contentLength > 0) { + byte[] buf = new byte[contentLength]; + int read = 0; + while (read < contentLength) { + int n = in.read(buf, read, contentLength - read); + if (n < 0) { + break; + } + read += n; + } + body = new String(buf, 0, read, StandardCharsets.UTF_8); + } + return new Request(method, path, body, authorization); + } + + private static String reason(int status) { + switch (status) { + case 200: + return "OK"; + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + default: + return "Status"; + } + } + + private static void writeChunked(OutputStream out, byte[] body) throws IOException { + // split into small chunks so a multi-KB value spans several, exercising the chunked decoder + final int chunkSize = 64; + for (int off = 0; off < body.length; off += chunkSize) { + int len = Math.min(chunkSize, body.length - off); + out.write((Integer.toHexString(len) + "\r\n").getBytes(StandardCharsets.US_ASCII)); + out.write(body, off, len); + out.write("\r\n".getBytes(StandardCharsets.US_ASCII)); + } + out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); // terminal chunk + } + + private static void writeOversized(OutputStream out, long bodyBytes) throws IOException { + // chunked body of the requested size, all whitespace after the opening brace so the JSON lexer keeps + // consuming (no per-value limit) until the client trips its response-size cap. The client aborts and + // closes the connection mid-stream once the cap is crossed, so tolerate the write failing under us + out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + final int chunkLen = 64 * 1024; + final byte[] chunk = new byte[chunkLen]; + Arrays.fill(chunk, (byte) ' '); + chunk[0] = '{'; // open an object once; the rest is whitespace, an unterminated body the cap cuts short + final byte[] crlf = "\r\n".getBytes(StandardCharsets.US_ASCII); + try { + long remaining = bodyBytes; + while (remaining > 0) { + final int len = (int) Math.min(chunkLen, remaining); + out.write((Integer.toHexString(len) + "\r\n").getBytes(StandardCharsets.US_ASCII)); + out.write(chunk, 0, len); + out.write(crlf); + chunk[0] = ' '; // only the first chunk opens the object; the rest is pure whitespace + remaining -= len; + } + out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch (IOException ignore) { + // expected: the client aborts on its response-size cap mid-stream and closes the connection + } + } + + private static void writeResponse(OutputStream out, MockResponse response) throws IOException { + if (response.rawResponse != null) { + out.write(response.rawResponse.getBytes(StandardCharsets.US_ASCII)); + out.flush(); + return; + } + if (response.oversizedBodyBytes > 0) { + writeOversized(out, response.oversizedBodyBytes); + return; + } + if (response.stall) { + // send chunked headers then block without sending the body, so the client must abort on its + // own configured deadline rather than wedging on the HttpClient default timeout + out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + out.flush(); + try { + Thread.sleep(30_000); + } catch (InterruptedException ignore) { + } + return; + } + byte[] bodyBytes = response.body.getBytes(StandardCharsets.UTF_8); + StringSink head = new StringSink(); + head.put("HTTP/1.1 ").put(response.status).put(' ').put(reason(response.status)).put("\r\n"); + head.put("Content-Type: application/json\r\n"); + if (response.chunked) { + head.put("Transfer-Encoding: chunked\r\n"); + head.put("\r\n"); + out.write(head.toString().getBytes(StandardCharsets.US_ASCII)); + writeChunked(out, bodyBytes); + } else { + head.put("Content-Length: ").put(bodyBytes.length).put("\r\n"); + head.put("\r\n"); + out.write(head.toString().getBytes(StandardCharsets.US_ASCII)); + out.write(bodyBytes); + } + out.flush(); + } + + private void acceptLoop() { + while (!serverSocket.isClosed()) { + try { + Socket socket = serverSocket.accept(); + connSockets.add(socket); + Thread connThread = new Thread(() -> handleConnection(socket), "mock-oidc-conn"); + connThread.setDaemon(true); + connThreads.add(connThread); + connThread.start(); + } catch (IOException e) { + // server socket closed, stop accepting + return; + } + } + } + + private void handleConnection(Socket socket) { + try (InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) { + Request request; + while ((request = readRequest(in)) != null) { + requestAuthHeaders.add(request.authorization); + MockResponse response = 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 + } 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 dropConnection; + long oversizedBodyBytes; + String rawResponse; + boolean stall; + + MockResponse(int status, String body, boolean chunked) { + this.status = status; + this.body = body; + this.chunked = chunked; + } + } + + public static class Request { + final String authorization; + final String body; + final String method; + final String path; + + Request(String method, String path, String body, String authorization) { + this.method = method; + this.path = path; + this.body = body; + this.authorization = authorization; + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java new file mode 100644 index 00000000..d01c45cd --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java @@ -0,0 +1,3362 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.Os; +import io.questdb.client.std.Unsafe; +import io.questdb.client.std.str.StringSink; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +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 { + + static { + // The default device-code prompt opens a browser when one is available. Developer machines have + // one, so disable the launch process-wide for the whole test class; otherwise every flow that + // reaches the prompt (e.g. a fromQuestDB or builder test) would pop a real browser tab. + System.setProperty("questdb.client.oidc.open.browser", "false"); + } + + private static final String DEVICE_PATH = "/device"; + private static final JsonParser NOOP_JSON_PARSER = (code, tag, position) -> { + }; + private static final String SETTINGS_PATH = "/settings"; + private static final String TOKEN_PATH = "/token"; + private static final String WELL_KNOWN_PATH = "/.well-known/openid-configuration"; + + @Test(timeout = 30_000) + public void testAccessDeniedSurfacesOauthError() throws Exception { + assertMemoryLeak(() -> { + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"the user declined\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("the user declined")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testAllControlVerificationUriCompleteTreatedAsAbsent() throws Exception { + assertMemoryLeak(() -> { + // a verification_uri_complete that is all control chars is non-empty on the wire but sanitizes to + // empty; it must be treated as absent (null), so the prompt shows no blank "(or open this URL ...)" + // line and the browser launcher is never handed an empty string + String allControl = jsonUnicodeEscape(0x0001) + jsonUnicodeEscape(0x0002) + jsonUnicodeEscape(0x0003); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"verification_uri_complete\":\"" + allControl + "\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-OK", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + Assert.assertNull(challenge.getVerificationUriComplete()); + } + }); + } + + @Test(timeout = 30_000) + public void testAllControlVerificationUriRejectedAsIncomplete() throws Exception { + assertMemoryLeak(() -> { + // a verification_uri made entirely of control chars is non-empty on the wire but sanitizes to empty + // - it would display as a blank URL the user cannot open, so the response is rejected as incomplete + // (the valid token below would let an unfixed client proceed to a successful but unusable sign-in) + String allControl = jsonUnicodeEscape(0x0001) + jsonUnicodeEscape(0x0002); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"" + allControl + "\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an all-control verification_uri to be rejected as incomplete"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testAudienceParameterSentToDeviceEndpoint() throws Exception { + assertMemoryLeak(() -> { + // the optional audience builder parameter must be url-encoded into the device authorization request + AtomicReference deviceBody = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceBody.set(body); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-AUD", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .audience("api://questdb") + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + Assert.assertEquals("ACCESS-AUD", auth.signIn()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb")); + } + }); + } + + @Test(timeout = 30_000) + public void testAudienceSentOnRefresh() throws Exception { + assertMemoryLeak(() -> { + // the audience must also be url-encoded into the refresh request, matching the Python client + AtomicReference refreshBody = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + refreshBody.set(body); + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 60)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .audience("api://questdb") + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); // force the silent-refresh path on the next call + Assert.assertEquals("ACCESS-2", auth.signIn()); + Assert.assertTrue(refreshBody.get(), refreshBody.get().contains("audience=api%3A%2F%2Fquestdb")); + } + }); + } + + @Test(timeout = 30_000) + public void testBuilderIssuerPinAcceptsMatchingOrigin() throws Exception { + assertMemoryLeak(() -> { + // endpoints that belong to the pinned issuer origin are accepted; only the origin is pinned, so + // the differing paths of the device and token endpoints are fine + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/as/device") + .tokenEndpoint("https://idp.example/as/token") + .issuer("https://idp.example") + .build() + ) { + // accepted: build() did not reject the matching-origin endpoints + } + }); + } + + @Test(timeout = 30_000) + public void testBuilderIssuerPinRejectsOffOriginEndpoints() { + // the token/device endpoints do not belong to the pinned issuer origin; build() must reject them + // rather than send the device code and refresh token outside the trusted issuer + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/device") + .tokenEndpoint("https://idp.example/token") + .issuer("https://other-idp.example") + .build() + ) { + Assert.fail("expected the issuer pin to reject off-origin endpoints"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("does not match the issuer origin")); + } + } + + @Test(timeout = 30_000) + public void testBuilderRejectsMissingRequiredOptions() { + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder().deviceAuthorizationEndpoint("https://h/d").tokenEndpoint("https://h/t").build()) { + Assert.fail("expected clientId validation to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("clientId")); + } + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder().clientId("c").tokenEndpoint("https://h/t").build()) { + Assert.fail("expected deviceAuthorizationEndpoint validation to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("deviceAuthorizationEndpoint")); + } + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder().clientId("c").deviceAuthorizationEndpoint("https://h/d").build()) { + Assert.fail("expected tokenEndpoint validation to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("tokenEndpoint")); + } + } + + @Test(timeout = 30_000) + public void testBuilderRejectsNonPositiveHttpTimeout() { + // every other timing input is clamped; a non-positive HTTP timeout yields an already-expired read + // deadline and an unbounded recv(int), so the setter rejects it (matching Sender.Builder) + for (int bad : new int[]{0, -1}) { + try { + OidcDeviceAuth.builder().httpTimeoutMillis(bad); + Assert.fail("expected httpTimeoutMillis(" + bad + ") to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpTimeoutMillis")); + } + } + } + + @Test(timeout = 30_000) + public void testBuilderRejectsSplitOriginEndpoints() { + // the token and device authorization endpoints are on different origins; RFC 8628 co-locates them + // on one authorization server, so build() must refuse to spread the credential POSTs across hosts + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://device.example/device") + .tokenEndpoint("https://token.example/token") + .build() + ) { + Assert.fail("expected split-origin endpoints to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("different origins")); + } + } + + @Test(timeout = 30_000) + public void testChallengeStripsBidiAndZeroWidthFromDisplayFields() throws Exception { + assertMemoryLeak(() -> { + // a hostile or MITM'd IdP smuggles bidi/zero-width formatting into the display fields. Here a + // right-to-left override (U+202E) arrives as a JSON unicode escape, which this client's lexer + // decodes into the real character before it reaches the prompt; a BOM, a zero-width space and a + // bidi isolate arrive the same way. The challenge shown to the user must strip them all, so the + // verification URL a human reads matches the one their browser opens + String evilUri = "https://verify.example/" + jsonUnicodeEscape(0x202E) + "evil"; // RTL override + String evilComplete = "https://verify.example/" + jsonUnicodeEscape(0xFEFF) + "device?x=1"; // BOM + String evilUserCode = "W" + jsonUnicodeEscape(0x200B) + "D" + jsonUnicodeEscape(0x2066) + "JB"; // ZWSP + LRI + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"" + evilUserCode + "\"," + + "\"verification_uri\":\"" + evilUri + "\"," + + "\"verification_uri_complete\":\"" + evilComplete + "\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-OK", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + // the bidi/zero-width/BOM characters are removed, the readable text is preserved + Assert.assertEquals("https://verify.example/evil", challenge.getVerificationUri()); + Assert.assertEquals("https://verify.example/device?x=1", challenge.getVerificationUriComplete()); + Assert.assertEquals("WDJB", challenge.getUserCode()); + assertNoUnsafeDisplayChars(challenge.getUserCode()); + assertNoUnsafeDisplayChars(challenge.getVerificationUri()); + assertNoUnsafeDisplayChars(challenge.getVerificationUriComplete()); + } + }); + } + + @Test(timeout = 30_000) + public void testChallengeStripsControlCharactersFromDisplayFields() throws Exception { + assertMemoryLeak(() -> { + // an attacker-influenced device-auth response embeds ANSI/control characters; the challenge + // shown to the user must have them stripped so it cannot rewrite or spoof the terminal + String evilUserCode = "WD\u001b[2JJB"; // ESC clear-screen sequence + String evilUri = "https://verify.example/\r\nFAKE: enter 000"; // CRLF line injection + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"" + evilUserCode + "\"," + + "\"verification_uri\":\"" + evilUri + "\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-OK", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + // the control characters are removed, the rest of the value is preserved + Assert.assertEquals("WD[2JJB", challenge.getUserCode()); + Assert.assertEquals("https://verify.example/FAKE: enter 000", challenge.getVerificationUri()); + assertNoControlChars(challenge.getUserCode()); + assertNoControlChars(challenge.getVerificationUri()); + } + }); + } + + @Test(timeout = 30_000) + public void testChallengeStripsLoneSurrogates() throws Exception { + assertMemoryLeak(() -> { + // a hostile IdP smuggles unpaired UTF-16 surrogates into display fields via single backslash-u-XXXX escapes + // the lexer emits verbatim (it does not pair them). codePointAt surfaces a lone surrogate as a + // SURROGATE code point, which the sanitizer must strip - while a legitimate adjacent high+low pair + // (an emoji) that codePointAt reassembles survives. + String loneHigh = jsonUnicodeEscape(0xD83D); // high surrogate, no low half + String loneLow = jsonUnicodeEscape(0xDE00); // low surrogate, no high half + String emoji = jsonUnicodeEscape(0xD83D) + jsonUnicodeEscape(0xDE00); // U+1F600, a valid pair + String evilUserCode = "WD" + loneHigh + "JB"; + String evilUri = "https://verify.example/" + loneLow + "evil" + emoji; + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"" + evilUserCode + "\"," + + "\"verification_uri\":\"" + evilUri + "\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-OK", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + // the unpaired surrogates are removed; the readable text and the legitimate emoji survive + Assert.assertEquals("WDJB", challenge.getUserCode()); + Assert.assertEquals("https://verify.example/evil" + new String(Character.toChars(0x1F600)), + challenge.getVerificationUri()); + assertNoUnsafeDisplayChars(challenge.getUserCode()); + assertNoUnsafeDisplayChars(challenge.getVerificationUri()); + } + }); + } + + @Test(timeout = 30_000) + public void testChallengeStripsSupplementaryPlaneFormatChars() throws Exception { + assertMemoryLeak(() -> { + // a hostile IdP smuggles a supplementary-plane (>= U+10000) format char - U+E0001 LANGUAGE TAG, + // an invisible Unicode "tag" character (category Cf) used to hide or spoof text - via a + // surrogate-pair JSON unicode escape the lexer reassembles. A per-UTF-16-unit filter misses it + // (each surrogate half is neither a control nor Cf); the sanitizer must judge it per code point + // and strip it, while leaving a legitimate astral character (an emoji) intact. + String evilTag = jsonUnicodeEscape(0xDB40) + jsonUnicodeEscape(0xDC01); // U+E0001 as a surrogate pair + String emoji = jsonUnicodeEscape(0xD83D) + jsonUnicodeEscape(0xDE00); // U+1F600 grinning face + String evilUserCode = "WD" + evilTag + "JB"; + String evilUri = "https://verify.example/" + evilTag + "evil" + emoji; + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"" + evilUserCode + "\"," + + "\"verification_uri\":\"" + evilUri + "\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-OK", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + // the invisible tag char is removed; the readable text and the legitimate emoji survive + Assert.assertEquals("WDJB", challenge.getUserCode()); + Assert.assertEquals("https://verify.example/evil" + new String(Character.toChars(0x1F600)), + challenge.getVerificationUri()); + assertNoUnsafeDisplayChars(challenge.getUserCode()); + assertNoUnsafeDisplayChars(challenge.getVerificationUri()); + } + }); + } + + @Test(timeout = 30_000) + public void testChunkedTokenResponseParses() throws Exception { + assertMemoryLeak(() -> { + // real IdPs use Transfer-Encoding: chunked; a multi-KB id token split across chunks must parse + String idToken = TestUtils.repeat("a", 3000); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.chunkedJson(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.chunkedJson(200, tokenJson("ACCESS-CHUNKED", idToken, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { + // groups-in-token mode serves the id token; it arrived chunked and is 3 KB long + Assert.assertEquals(idToken, auth.signIn()); + } + }); + } + + @Test(timeout = 30_000) + public void testClearCacheForcesFreshSignIn() throws Exception { + assertMemoryLeak(() -> { + // clearCache() must drop the cached token AND the refresh token, so the next signIn() runs a + // fresh interactive sign-in (a device-code grant) rather than a silent refresh + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger refreshCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + refreshCalls.incrementAndGet(); + return MockOidcServer.json(200, tokenJson("ACCESS-R", null, "REFRESH-R", 3600)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + auth.clearCache(); + // the next call must run a second device-code sign-in, not a refresh (the refresh token was dropped) + Assert.assertEquals("ACCESS-1", auth.signIn()); + Assert.assertEquals("clearCache must force a second interactive sign-in", 2, deviceCalls.get()); + Assert.assertEquals("clearCache must drop the refresh token so no refresh is attempted", 0, refreshCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testClockSkewCappedAtHalfTokenLifetime() throws Exception { + assertMemoryLeak(() -> { + // the fixed 30s clock skew is capped at half the token lifetime (matching the Python client), so a + // short-lived token is served from cache for the first half of its life rather than being treated + // as expired the instant it is issued - which a flat 30s skew would do to any sub-60s token + AtomicInteger refreshCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + refreshCalls.incrementAndGet(); + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 10)); // 10s lifetime + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + // a flat 30s skew would mark this 10s token expired immediately (now < expiresAt - 30s is + // false); the lifetime/2 cap (5s) keeps it valid, so the second call is a cache hit, not a refresh + Assert.assertEquals("ACCESS-1", auth.signIn()); + Assert.assertEquals("ACCESS-1", auth.signIn()); + Assert.assertEquals("the capped skew kept the short token cached - no refresh", 0, refreshCalls.get()); + + // once the token is genuinely past expiry, signIn() takes the silent-refresh path + expireCachedToken(auth); + Assert.assertEquals("ACCESS-2", auth.signIn()); + Assert.assertEquals(1, refreshCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testCloseCancelsInFlightSignIn() throws Exception { + // a sign-in is waiting for the user: the token endpoint keeps returning authorization_pending. + // close() from another caller must abort the in-flight signIn() promptly, instead of letting + // it hold the instance lock and poll until the device code expires + assertMemoryLeak(() -> { + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 10)); + } + return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}"); + }; + CountDownLatch polling = new CountDownLatch(1); + AtomicReference outcome = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, challenge -> polling.countDown())) { + Thread signIn = new Thread(() -> { + try { + auth.signIn(); + outcome.set(new AssertionError("signIn() should have been cancelled by close()")); + } catch (Throwable t) { + outcome.set(t); + } + }, "oidc-sign-in"); + signIn.setDaemon(true); + signIn.start(); + // wait until the flow has prompted and is polling, then close from this thread + Assert.assertTrue("the sign-in did not reach the polling stage", polling.await(10, TimeUnit.SECONDS)); + auth.close(); + signIn.join(10_000); + Assert.assertFalse("signIn() did not return promptly after close()", signIn.isAlive()); + Throwable t = outcome.get(); + Assert.assertTrue("expected an OidcAuthException, got " + t, t instanceof OidcAuthException); + Assert.assertTrue(t.getMessage(), t.getMessage().contains("closed")); + } + }); + } + + @Test(timeout = 30_000) + public void testConcurrentSignInStartsSingleSignIn() throws Exception { + assertMemoryLeak(() -> { + // several callers race signIn() on a fresh instance; the synchronized method must serialize + // them so exactly one interactive sign-in runs and the rest get the cached token + AtomicInteger deviceCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-CONCURRENT", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + int workerCount = 4; + CountDownLatch ready = new CountDownLatch(workerCount); + CountDownLatch go = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + String[] tokens = new String[workerCount]; + Thread[] workers = new Thread[workerCount]; + for (int i = 0; i < workerCount; i++) { + final int idx = i; + workers[i] = new Thread(() -> { + ready.countDown(); + try { + go.await(); + tokens[idx] = auth.signIn(); + } catch (Throwable t) { + error.set(t); + } + }, "oidc-signIn-" + i); + workers[i].setDaemon(true); + workers[i].start(); + } + Assert.assertTrue(ready.await(10, TimeUnit.SECONDS)); + go.countDown(); + for (Thread w : workers) { + w.join(10_000); + } + Assert.assertNull("a worker failed: " + error.get(), error.get()); + Assert.assertEquals("only one interactive sign-in must run", 1, deviceCalls.get()); + for (int i = 0; i < workerCount; i++) { + Assert.assertEquals("ACCESS-CONCURRENT", tokens[i]); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDeviceCodeLifetimeClamped() throws Exception { + assertMemoryLeak(() -> { + // a missing or zero expires_in defaults to 600s, and an absurd value is capped at 1800s (matching + // the Python client), so a hostile or buggy provider cannot make the client poll for an absurd + // duration; the clamped value is the one shown to the user (challenge.getExpiresInSeconds()) + AtomicReference shown = new AtomicReference<>(); + MockOidcServer.Handler missingExpiry = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{\"device_code\":\"DEV\",\"user_code\":\"UC\"," + + "\"verification_uri\":\"https://verify.example/device\",\"interval\":1}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-DEFAULT-TTL", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(missingExpiry); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-DEFAULT-TTL", auth.signIn()); + Assert.assertEquals(600, shown.get().getExpiresInSeconds()); + } + MockOidcServer.Handler absurdExpiry = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 999_999)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-CAPPED-TTL", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(absurdExpiry); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-CAPPED-TTL", auth.signIn()); + Assert.assertEquals(1800, shown.get().getExpiresInSeconds()); + } + }); + } + + @Test(timeout = 30_000) + public void testDeviceEndpointReturnsOauthError() throws Exception { + assertMemoryLeak(() -> { + // the device authorization request itself is rejected (e.g. the client is not allowed) + MockOidcServer.Handler handler = (method, path, body) -> + MockOidcServer.json(400, "{\"error\":\"invalid_client\",\"error_description\":\"unknown client\"}"); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("invalid_client", e.getOauthError()); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unknown client")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDeviceFlowHappyPath() throws Exception { + assertMemoryLeak(() -> { + AtomicInteger tokenCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + Assert.assertTrue(body, body.contains("client_id=questdb")); + Assert.assertTrue(body, body.contains("scope=openid")); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + // first poll: still pending, second poll: success + Assert.assertTrue(body, body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code")); + Assert.assertTrue(body, body.contains("device_code=DEV-CODE")); + if (tokenCalls.getAndIncrement() == 0) { + return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + Assert.assertEquals("Bearer ACCESS-1", auth.getAuthorizationHeaderValue()); + Assert.assertEquals(2, tokenCalls.get()); + + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + Assert.assertEquals("WDJB-MJHT", challenge.getUserCode()); + Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri()); + Assert.assertEquals("https://verify.example/device?user_code=WDJB-MJHT", challenge.getVerificationUriComplete()); + } + }); + } + + @Test(timeout = 30_000) + public void testNonNumericStatusCodeRejected() throws Exception { + assertMemoryLeak(() -> { + // a hostile or MITM'd identity provider returns a status line whose status-code token carries an + // ANSI escape (the HTTP header parser copies the token verbatim apart from SP/CR/LF). A status code + // is bare digits, so a non-digit byte is a malformed or hostile status line: the client must reject + // it - never echoing its bytes (which could rewrite a terminal or forge a log line) and never + // trusting its leading digit as a 2xx success gate + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\"" + + "}}"); + } + // status code "2[m00": an ANSI reset spliced into the token. The leading '2' would pass a + // first-char success check, but the non-digit bytes must make the client reject the response + return MockOidcServer.raw("HTTP/1.1 2\u001b[m00 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: 2\r\n" + + "\r\n" + + "{}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + auth.signIn(); + Assert.fail("expected a malformed status code to be rejected"); + } catch (OidcAuthException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("malformed HTTP status code")); + Assert.assertFalse("raw ESC must not leak into the message: " + msg, msg.indexOf('\u001b') >= 0); + } + } + }); + } + + @Test(timeout = 30_000) + public void testNonNumericStatusCodeRejectedDuringPolling() throws Exception { + assertMemoryLeak(() -> { + // the malformed-status guard must also fire on the token-poll path, where readResponse handles the + // POSTs that carry the device code on every poll (testNonNumericStatusCodeRejected covers the + // device-authorization POST). The device step succeeds, then the token endpoint returns a status + // line whose status-code token splices in an ANSI escape; the client must reject it - never echoing + // its bytes, never trusting its leading '2' as a 2xx success gate + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.raw("HTTP/1.1 2\u001b[m00 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: 2\r\n" + + "\r\n" + + "{}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a malformed status code on the poll path to be rejected"); + } catch (OidcAuthException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("malformed HTTP status code")); + Assert.assertFalse("raw ESC must not leak into the message: " + msg, msg.indexOf('\u001b') >= 0); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryDefaultsScopeToOpenid() throws Exception { + assertMemoryLeak(() -> { + AtomicReference serverRef = new AtomicReference<>(); + AtomicReference deviceBody = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + // settings advertise no scope, so the client must default to "openid" + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\"" + + "}}"); + } + if (DEVICE_PATH.equals(path)) { + deviceBody.set(body); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-SCOPE", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.assertEquals("ACCESS-SCOPE", auth.signIn()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); + Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("groups")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryIgnoresPreferencesKeys() throws Exception { + assertMemoryLeak(() -> { + // the unprivileged-writable "preferences" object tries to poison discovery (flip enabled + // off, flip groups-in-token, inject scope); only the trusted top-level "config" object + // must feed discovery + AtomicReference serverRef = new AtomicReference<>(); + AtomicReference deviceBody = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.scope\":\"openid\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\"" + + "},\"preferences.version\":0,\"preferences\":{" + + "\"acl.oidc.enabled\":false," + + "\"acl.oidc.groups.encoded.in.token\":true," + + "\"acl.oidc.scope\":\"INJECTED\"" + + "}}"); + } + if (DEVICE_PATH.equals(path)) { + deviceBody.set(body); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-TRUSTED", "ID-TRUSTED", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + // enabled stayed true (no DoS), groups-in-token stayed false (access token served), + // scope stayed "openid" (no injection) + Assert.assertEquals("ACCESS-TRUSTED", auth.signIn()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("scope=openid")); + Assert.assertFalse(deviceBody.get(), deviceBody.get().contains("INJECTED")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryReadsAudience() throws Exception { + assertMemoryLeak(() -> { + // the audience advertised by /settings (acl.oidc.audience) must be url-encoded into the device + // authorization request, matching the Python client + AtomicReference serverRef = new AtomicReference<>(); + AtomicReference deviceBody = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.audience\":\"api://questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\"" + + "}}"); + } + if (DEVICE_PATH.equals(path)) { + deviceBody.set(body); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-AUD-D", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.assertEquals("ACCESS-AUD-D", auth.signIn()); + Assert.assertTrue(deviceBody.get(), deviceBody.get().contains("audience=api%3A%2F%2Fquestdb")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryRejectsMissingClientId() throws Exception { + assertMemoryLeak(() -> { + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + // OIDC enabled, endpoints advertised, but no client id + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\"" + + "}}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected discovery to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("client id")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryRejectsMissingTokenEndpoint() throws Exception { + assertMemoryLeak(() -> { + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + // OIDC enabled with a client id, but no token endpoint + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl(DEVICE_PATH) + "\"" + + "}}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected discovery to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testDiscoveryTransportFailureDoesNotLeakNativeMemory() throws Exception { + // discoverSettings allocates a JSON lexer (NATIVE_TEXT_PARSER_RSS) and an HTTP client (NATIVE_DEFAULT + // buffers) and frees both in a finally; a transport failure during discovery must not leak either. + // The module's assertMemoryLeak does not reliably flag single-tag growth, so measure both tags + // directly. Measuring only the parser tag (as an earlier version did) was blind to a leak of the + // HTTP client's native buffers - the resource most likely to be left dangling on the failure path. + int deadPort; + try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + deadPort = probe.getLocalPort(); + } // closed now - nothing listens on deadPort + long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); + long clientMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB("http://127.0.0.1:" + deadPort, insecure())) { + Assert.fail("expected discovery to fail against a dead port"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not reach the QuestDB server")); + } + Assert.assertEquals("the discovery JSON lexer native buffer leaked", + parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS)); + Assert.assertEquals("the discovery HTTP client native buffers leaked", + clientMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT)); + } + + @Test(timeout = 30_000) + public void testDuplicateJsonKeysDoNotConcatenate() throws Exception { + assertMemoryLeak(() -> { + // a buggy/hostile IdP repeats a key; the parser must keep the last value, not concatenate it + // onto the first (e.g. AAABBB), which would corrupt the served token and the device code + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV-CODE\"," + + "\"user_code\":\"WRONG\",\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, "{\"token_type\":\"Bearer\",\"expires_in\":3600," + + "\"access_token\":\"AAA\",\"access_token\":\"ACCESS-LAST\"}"); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + // the duplicate access_token resolves to the last value, not "AAAACCESS-LAST" + Assert.assertEquals("ACCESS-LAST", auth.signIn()); + // the duplicate user_code resolves to the last value, not "WRONGWDJB-MJHT" + Assert.assertEquals("WDJB-MJHT", shown.get().getUserCode()); + } + }); + } + + @Test(timeout = 30_000) + public void testEndpointParseRejectsDisplayUnsafeUrl() { + // a url carrying a display-unsafe character is rejected, and the rejection message itself must carry + // none: otherwise a tampered /settings or discovery endpoint url could reorder, hide or forge the + // log line / exception text it lands in. The control-char scan alone does not catch these higher + // code points (bidi, zero-width, BOM, supplementary-plane tag chars), the last scanned per code point + String[] unsafe = { + String.valueOf((char) 0x202E), // right-to-left override + String.valueOf((char) 0x200B), // zero-width space + String.valueOf((char) 0xFEFF), // BOM / zero-width no-break space + new String(Character.toChars(0xE0001)) // U+E0001 LANGUAGE TAG (supplementary-plane format char) + }; + for (int i = 0; i < unsafe.length; i++) { + String marker = unsafe[i]; + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/dev" + marker + "ice") + .tokenEndpoint("https://idp.example/t") + .build() + ) { + Assert.fail("expected the display-unsafe url to be rejected [index=" + i + "]"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("illegal character")); + // the raw unsafe character must not survive into the message + assertNoUnsafeDisplayChars(e.getMessage()); + } + } + } + + @Test(timeout = 30_000) + public void testEndpointParseRejectsMalformedUrls() { + // Endpoint.parse rejects malformed endpoint URLs at build time + assertBuildFails("ftp://idp/d", "https://idp/t", "expected http or https"); + assertBuildFails("idp/d", "https://idp/t", "expected a scheme"); + assertBuildFails("https://idp/d", "https://idp:notaport/t", "could not parse the port"); + assertBuildFails("https:///d", "https://idp/t", "the host is empty"); + assertBuildFails("https://[::1]:9000/d", "https://idp/t", "IPv6 literal hosts are not supported"); + // userinfo (user@host or user:pass@host) is unsupported: the HTTP layer would connect to the literal + // "user@host", so reject it rather than mis-resolve it or report a misleading port-parse error + assertBuildFails("https://user@idp/d", "https://idp/t", "userinfo"); + assertBuildFails("https://idp/d", "https://user:pass@idp/t", "userinfo"); + // an out-of-range port (0, negative, or above 65535) is rejected rather than passed to the transport + assertBuildFails("https://idp:99999/d", "https://idp/t", "between 1 and 65535"); + assertBuildFails("https://idp:0/d", "https://idp/t", "between 1 and 65535"); + assertBuildFails("https://idp:-1/d", "https://idp/t", "between 1 and 65535"); + assertBuildFails("https://idp/d", "https://idp:70000/t", "between 1 and 65535"); + // a host carrying control characters or whitespace (e.g. a smuggled CR/LF that would inject into the + // outbound Host header) is rejected rather than passed verbatim to the transport + assertBuildFails("https://ho\r\nst/d", "https://idp/t", "illegal character"); + assertBuildFails("https://h\tst/d", "https://idp/t", "illegal character"); + assertBuildFails("https://h st/d", "https://idp/t", "illegal character"); + assertBuildFails("https://idp/d", "https://e\nvil/t", "illegal character"); + // a control character or whitespace in the path or query is rejected too: postForm sends the path + // verbatim on the request line, so a smuggled CR/LF there would inject a header / smuggle a request + assertBuildFails("https://idp/devic\r\ne", "https://idp/t", "illegal character"); + assertBuildFails("https://idp/d", "https://idp/toke\r\nX-Injected:1", "illegal character"); + assertBuildFails("https://idp/d", "https://idp/t?a=b\nc", "illegal character"); + } + + @Test(timeout = 30_000) + public void testEscapedDeviceCodeRoundTripsDecoded() throws Exception { + assertMemoryLeak(() -> { + // an IdP that escapes a character in device_code (here a slash) must have it decoded before the + // client posts it back, otherwise the polled device_code never matches what the IdP issued + AtomicReference pollBody = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\\/CODE\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + pollBody.set(body); + return MockOidcServer.json(200, tokenJson("ACCESS-DC", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-DC", auth.signIn()); + // device_code was "DEV\/CODE" in JSON; decoded to "DEV/CODE" and url-encoded as DEV%2FCODE + Assert.assertTrue(pollBody.get(), pollBody.get().contains("device_code=DEV%2FCODE")); + } + }); + } + + @Test(timeout = 30_000) + public void testEscapedErrorDescriptionDecoded() throws Exception { + assertMemoryLeak(() -> { + // an error_description with JSON-escaped characters must be decoded in the exception message, + // not shown with literal backslashes + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"it\\\"s a \\/ test\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + // the escapes are decoded, not shown literally + Assert.assertTrue(e.getMessage(), e.getMessage().contains("it\"s a / test")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("\\/")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testEscapedVerificationUrlIsUnescapedForDisplay() throws Exception { + assertMemoryLeak(() -> { + // some identity providers JSON-escape forward slashes (PHP json_encode does by default), e.g. + // "https:\/\/...". The challenge shown to the user must decode the escapes, not display literal + // backslashes that break the link + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV-CODE\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https:\\/\\/verify.example\\/device\"," + + "\"verification_uri_complete\":\"https:\\/\\/verify.example\\/device?user_code=WDJB-MJHT\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-ESC", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-ESC", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri()); + Assert.assertEquals("https://verify.example/device?user_code=WDJB-MJHT", challenge.getVerificationUriComplete()); + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbDiscoversDeviceEndpointFromIssuer() throws Exception { + assertMemoryLeak(() -> { + // the server advertises a token endpoint but not the device authorization endpoint (today's + // servers); pinning the issuer lets the client discover the device endpoint from the issuer's + // .well-known/openid-configuration document and complete the flow + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null)); + } + if (WELL_KNOWN_PATH.equals(path)) { + return MockOidcServer.json(200, wellKnownJson(server.httpUrl(DEVICE_PATH), server.httpUrl(TOKEN_PATH), server.httpUrl(""))); + } + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-WK", "ID-WK", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + // the issuer is the mock itself, which also serves the .well-known document and the IdP endpoints + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("")))) { + // settings advertise groups.encoded.in.token=true, so signIn() returns the id token + Assert.assertEquals("ID-WK", auth.signIn()); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbDiscoveryDocMissingDeviceEndpointRejected() throws Exception { + assertMemoryLeak(() -> { + // discovery runs against the pinned issuer, but the discovery document does not advertise a + // device authorization endpoint (the identity provider lacks the device grant); fail clearly + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null)); + } + // a discovery document with a token endpoint and issuer but no device_authorization_endpoint + return MockOidcServer.json(200, "{" + + "\"issuer\":\"" + server.httpUrl("") + "\"," + + "\"token_endpoint\":\"" + server.httpUrl(TOKEN_PATH) + "\"" + + "}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("")))) { + Assert.fail("expected discovery to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device_authorization_endpoint")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbDiscoveryRunsFlow() throws Exception { + assertMemoryLeak(() -> { + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH))); + } + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-D", "ID-D", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + // discovery advertises groups.encoded.in.token=true, so signIn() must return the id token + Assert.assertEquals("ID-D", auth.signIn()); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbIssuerPinAcceptsOffOriginDiscoveredEndpoints() throws Exception { + assertMemoryLeak(() -> { + // The Google case: the pinned issuer hosts its discovery document on one origin but serves its + // token and device endpoints on another. /settings advertises neither endpoint, so both are + // discovered from the issuer's own .well-known (a trusted, out-of-band source) and must be accepted + // wherever the issuer hosts them, NOT origin-pinned to the issuer. An endpoint the untrusted + // /settings advertised IS still origin-pinned - see testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint. + AtomicReference idpRef = new AtomicReference<>(); + AtomicReference issuerRef = new AtomicReference<>(); + // the IdP endpoint host: a different origin (port) than the issuer below + MockOidcServer.Handler idpHandler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OFF", null, null, 3600)); + }; + try (MockOidcServer idp = new MockOidcServer(idpHandler)) { + idpRef.set(idp); + // the QuestDB server doubles as the pinned issuer: it serves /settings (advertising neither + // endpoint) and the .well-known document, which points the device/token endpoints at the + // off-origin idp + MockOidcServer.Handler issuerHandler = (method, path, body) -> { + MockOidcServer endpointHost = idpRef.get(); + MockOidcServer iss = issuerRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.scope\":\"openid\"" + + "}}"); + } + if (WELL_KNOWN_PATH.equals(path)) { + return MockOidcServer.json(200, wellKnownJson( + endpointHost.httpUrl(DEVICE_PATH), endpointHost.httpUrl(TOKEN_PATH), iss.httpUrl(""))); + } + return MockOidcServer.json(404, "{}"); + }; + try (MockOidcServer issuer = new MockOidcServer(issuerHandler)) { + issuerRef.set(issuer); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(issuer.httpUrl(""), insecure().issuer(issuer.httpUrl("")))) { + // the off-origin discovered endpoints are accepted; the device flow completes against them + Assert.assertEquals("ACCESS-OFF", auth.signIn()); + } + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbIssuerPinRejectsOffOriginAdvertisedEndpoint() throws Exception { + assertMemoryLeak(() -> { + // the server advertises both endpoints directly, but they do not belong to the pinned issuer + // origin; the issuer pin must reject them rather than route credentials off the trusted issuer + // (this is the protection against a compromised-but-reachable server redirecting the sign-in) + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + return MockOidcServer.json(200, settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH))); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer("https://idp.attacker.example"))) { + Assert.fail("expected the issuer pin to reject the off-origin endpoints"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("is not on the pinned identity-provider origin")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbRejectsCrlfInjectedAdvertisedEndpoint() throws Exception { + assertMemoryLeak(() -> { + // a tampered /settings advertises a token endpoint whose path carries a JSON-escaped CR/LF; the + // lexer decodes it to real control characters, and Endpoint.parse must reject it rather than let + // it inject into the outbound request line (header smuggling against the identity provider) + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + String crlf = jsonUnicodeEscape(0x0d) + jsonUnicodeEscape(0x0a); + String injectedToken = server.httpUrl(TOKEN_PATH) + crlf + "X-Injected:1"; + return MockOidcServer.json(200, settingsJson(true, true, injectedToken, server.httpUrl(DEVICE_PATH))); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected the CR/LF-injected token endpoint to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("illegal character")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbRejectsInsecureServerUrl() { + // the default-secure fromQuestDB overload must reject an http:// QuestDB server url (the discovery + // response and the sign-in it bootstraps would travel in cleartext) unless insecure transport is + // explicitly opted in + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB("http://questdb.example:9000")) { + Assert.fail("expected an http server url to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("QuestDB server url")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http")); + } + } + + @Test(timeout = 30_000) + public void testFromQuestDbRejectsMissingDeviceEndpoint() throws Exception { + assertMemoryLeak(() -> { + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + // OIDC enabled, but no device authorization endpoint advertised (an older server) + return MockOidcServer.json(200, settingsJson(true, false, server.httpUrl(TOKEN_PATH), null)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected discovery to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testFromQuestDbRejectsOidcDisabled() throws Exception { + assertMemoryLeak(() -> { + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> + MockOidcServer.json(200, settingsJson(false, false, serverRef.get().httpUrl(TOKEN_PATH), null)); + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected discovery to fail"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("OIDC is not enabled")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Exception { + assertMemoryLeak(() -> { + // the cached token expires and the refresh hits a transient non-JSON body (e.g. a gateway + // 502 HTML page). The client must fall back to the interactive flow, not propagate the parse + // failure out of signIn() + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger deviceCodeGrants = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + // a transient gateway error page instead of a token JSON + return MockOidcServer.json(502, "502 Bad Gateway"); + } + if (deviceCodeGrants.getAndIncrement() == 0) { + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); + // the cached token is expired and the refresh body is garbled, so the client must re-run + // the interactive flow instead of throwing the parse error + Assert.assertEquals("ACCESS-2", auth.signIn()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception { + assertMemoryLeak(() -> { + // an interactive signIn() is parked polling (authorization_pending), holding the instance + // lock for the whole device-code lifetime. A flush-path getToken() on another thread + // must NOT block behind it - it must fail fast, so a Sender flush is never stalled by a + // concurrent sign-in. (With the old synchronized model it blocked until the code expired.) + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 10)); + } + return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}"); + }; + CountDownLatch polling = new CountDownLatch(1); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, challenge -> polling.countDown())) { + Thread signIn = new Thread(() -> { + try { + auth.signIn(); + } catch (Throwable ignore) { + // expected: cancelled by close() at the end of the test + } + }, "oidc-sign-in"); + signIn.setDaemon(true); + signIn.start(); + try { + // wait until the interactive flow has prompted and is polling (it holds the lock now) + Assert.assertTrue("the sign-in did not reach the polling stage", polling.await(10, TimeUnit.SECONDS)); + // getToken() must return control promptly (here: throw), NOT block ~10s until + // the device code expires and signIn() releases the lock + long startNanos = System.nanoTime(); + try { + auth.getToken(); + Assert.fail("expected getToken() to fail fast while a sign-in is in progress"); + } catch (OidcAuthException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight sign-in", + elapsedMillis < 2_000); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); + } + } 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 testGetTokenDoesNotBlockBehindSilentRefresh() throws Exception { + assertMemoryLeak(() -> { + // the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not + // just an interactive sign-in: getToken() must fail fast rather than queue behind it. The + // cached token is forced expired so getToken() refreshes; the token endpoint blocks the + // refresh response until the test releases it, pinning the lock on the refresher thread while the + // second caller races for it + CountDownLatch refreshInFlight = new CountDownLatch(1); + CountDownLatch releaseRefresh = new CountDownLatch(1); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + refreshInFlight.countDown(); + try { + if (!releaseRefresh.await(30, TimeUnit.SECONDS)) { + Assert.fail("token refresh timeout expired"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 1)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); // initial device_code grant + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + auth.signIn(); // sign in once: caches ACCESS-1 and a refresh token + expireCachedToken(auth); // so the refresher thread's getToken() takes the refresh path + Thread refresher = new Thread(() -> { + try { + auth.getToken(); + } catch (Throwable ignore) { + // the refresh completes once released; a late error here is irrelevant to this test + } + }, "oidc-silent-refresh"); + refresher.setDaemon(true); + refresher.start(); + try { + Assert.assertTrue("the silent refresh did not start", refreshInFlight.await(10, TimeUnit.SECONDS)); + // a refresh holds the lock now; getToken() on this thread must fail fast, not block + long startNanos = System.nanoTime(); + try { + auth.getToken(); + Assert.fail("expected getToken() to fail fast while a refresh is in progress"); + } catch (OidcAuthException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + Assert.assertTrue("getToken() blocked " + elapsedMillis + "ms behind the in-flight refresh", + elapsedMillis < 2_000); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("in progress")); + } + } finally { + releaseRefresh.countDown(); + refresher.join(10_000); + } + } + }); + } + + @Test(timeout = 30_000) + public void testGetTokenRefreshesWithoutPrompting() throws Exception { + assertMemoryLeak(() -> { + // getToken() returns the cached token, silently refreshes it when it expires, and never + // prompts; if it cannot produce a token without an interactive sign-in, it throws + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger promptCalls = new AtomicInteger(); + AtomicBoolean refreshOk = new AtomicBoolean(true); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + return refreshOk.get() + ? MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 1)) + : MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) { + // before any sign-in, getToken() must not prompt - it throws + try { + auth.getToken(); + Assert.fail("expected getToken() to fail before sign-in"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no token")); + } + // sign in once interactively + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); + // the cached token is expired, so getToken() refreshes silently + Assert.assertEquals("ACCESS-2", auth.getToken()); + // now make the refresh fail; getToken() must throw, not start the device flow + refreshOk.set(false); + expireCachedToken(auth); + try { + auth.getToken(); + Assert.fail("expected getToken() to fail when the refresh is rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("interactive sign-in")); + } + // the device flow ran exactly once (the initial signIn), and the user was prompted once + Assert.assertEquals(1, deviceCalls.get()); + Assert.assertEquals(1, promptCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testGroupsInTokenButNoIdTokenFails() throws Exception { + assertMemoryLeak(() -> { + // groups encoded in token, but the IdP returns only an access token on the initial grant + // (e.g. the requested scope omitted openid); signIn() must fail with an actionable message + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-ONLY", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { + try { + auth.signIn(); + 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.signIn()); + } + }); + } + + @Test(timeout = 30_000) + public void testHttpSenderProviderFailureAfterFlushDoesNotCorruptSender() throws Exception { + assertMemoryLeak(() -> { + // regression: the per-request token must be pulled lazily when a row starts, never eagerly when + // the post-flush request is rebuilt. A provider that throws on a later pull (e.g. + // OidcDeviceAuth::getToken when a refresh fails) must NOT turn an already-successful + // flush into a thrown exception, and must NOT leave a half-built request that corrupts the + // sender so later rows go out malformed + MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.json(204, ""); + AtomicInteger pulls = new AtomicInteger(); + try (MockOidcServer server = new MockOidcServer(handler); + Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .protocolVersion(Sender.PROTOCOL_VERSION_V2) + .httpTokenProvider(() -> { + int n = pulls.incrementAndGet(); + if (n == 2) { + // the second pull - for the request after the first, successful flush - fails + throw new OidcAuthException("the cached token expired and could not be refreshed"); + } + return "TOKEN-" + n; + }) + .build()) { + // first batch: the token is pulled when the row starts (TOKEN-1); the flush sends it and must + // succeed. The failing *next* pull must not strike here - the eager post-flush pull was the bug + sender.table("t").doubleColumn("x", 1.0).atNow(); + sender.flush(); + + // next batch: the deferred pull runs when the row starts and the provider throws there; the + // failure must surface cleanly, leaving the previous successful flush and its data untouched + try { + sender.table("t").doubleColumn("x", 2.0).atNow(); + Assert.fail("expected the failing provider pull to surface on the next row"); + } catch (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(() -> { + // 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.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("incomplete device authorization")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testIdpEndpointsRequireHttpsExceptLoopback() throws Exception { + assertMemoryLeak(() -> { + // a non-loopback http identity-provider endpoint carries the device code and refresh token in + // cleartext, so it must be refused + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("http://idp.example/device") + .tokenEndpoint("https://idp.example/token") + .build() + ) { + Assert.fail("expected the http device authorization endpoint to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http")); + } + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/device") + .tokenEndpoint("http://idp.example/token") + .build() + ) { + Assert.fail("expected the http token endpoint to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("token endpoint")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http")); + } + // allowInsecureTransport must NOT relax the identity provider endpoints (unlike the QuestDB + // link), matching the Python client; a non-loopback http endpoint stays rejected, and the + // error says so + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("http://idp.example/device") + .tokenEndpoint("http://idp.example/token") + .allowInsecureTransport(true) + .build() + ) { + Assert.fail("allowInsecureTransport must not relax a non-loopback http identity provider endpoint"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("insecure http")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("allowInsecureTransport relaxes only the QuestDB")); + } + // loopback http is allowed without any flag: the request never leaves the host + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("http://127.0.0.1:9999/device") + .tokenEndpoint("http://127.0.0.1:9999/token") + .build() + ) { + // accepted: loopback endpoints never put the device code or refresh token on the network + } + }); + } + + @Test(timeout = 30_000) + public void testIssuerPathScopingAcceptsEndpointsUnderIssuerPath() throws Exception { + assertMemoryLeak(() -> { + // a path-based identity provider (Keycloak-style /realms/{realm}): the issuer carries a path and + // /settings advertises the endpoints under it, so the flow completes + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + if (SETTINGS_PATH.equals(path)) { + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/device") + "\"" + + "}}"); + } + if ("/realms/acme/device".equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-REALM", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) { + Assert.assertEquals("ACCESS-REALM", auth.signIn()); + } + } + }); + } + + @Test(timeout = 30_000) + public void testIssuerPathScopingRejectsEncodedSlash() throws Exception { + assertMemoryLeak(() -> { + // the device endpoint hides an extra path segment behind a %2f-encoded slash; decoding it would + // split acme%2fevil into acme/evil and slip the "/realms/acme" scope, so an encoded path separator + // must be rejected outright + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme%2fevil/device") + "\"" + + "}}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) { + Assert.fail("expected the %2f-encoded device endpoint to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testIssuerPathScopingRejectsEncodedTraversal() throws Exception { + assertMemoryLeak(() -> { + // the device endpoint hides a parent traversal as %2e%2e; decoding must unmask it and reject it, + // since the server would normalize /realms/acme/../evil to a different realm + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/%2e%2e/evil/device") + "\"" + + "}}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) { + Assert.fail("expected the encoded ..-traversal device endpoint to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testIssuerPathScopingRejectsSiblingRealm() throws Exception { + assertMemoryLeak(() -> { + // a tampered /settings advertises a token endpoint under a DIFFERENT realm on the same origin; the + // origin check alone would accept it, but path scoping must reject it + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + return MockOidcServer.json(200, "{\"config\":{" + + "\"acl.oidc.enabled\":true," + + "\"acl.oidc.client.id\":\"questdb\"," + + "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/evil/token") + "\"," + + "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/device") + "\"" + + "}}"); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) { + Assert.fail("expected the off-path (sibling realm) token endpoint to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() throws Exception { + // hardening: an encoded path separator can hide behind a SPLIT encoding (%2%66 -> %2f -> '/') or a + // double encoding (%252f), and a literal backslash is folded to '/' by decodePathSegments. Each lets an + // extra segment masquerade as being under the issuer path while a different raw path travels on the + // wire, so isEndpointUnderIssuerPath must reject them. Only the path is scoped; the origin matches here. + String issuer = "https://idp.example.com/realms/acme"; + // a genuine sub-path endpoint stays accepted + Assert.assertTrue(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/protocol/token", issuer)); + // split, double, and literal-backslash separators all resolve to a deeper /realms/acme/evil and are rejected + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%2%66evil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%252fevil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme\\evil/token", issuer)); + // a percent-encoded backslash (%5c / %5C) is the encoded form of the literal '\' above; the scan + // rejects it at the encoded level too, before decodePathSegments would fold it to '/' + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5cevil/token", issuer)); + Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5Cevil/token", issuer)); + } + + @Test(timeout = 30_000) + public void testLargeSplitTokenValueParsesWithConfiguredLexerSizing() throws Exception { + assertMemoryLeak(() -> { + // A real id_token (a JWT with group claims) runs to several KB, and a single JSON string value + // can arrive split across HTTP response fragments. OidcDeviceAuth must size its JSON lexer so + // such a split value still parses. This mirrors OidcDeviceAuth's production sizing + // (JSON_LEXER_CACHE_SIZE / JSON_LEXER_MAX_VALUE_BYTES); the original (1024, 1024) sizing + // rejected a >1024-byte split value with "String is too long". + String json = "{\"id_token\":\"" + TestUtils.repeat("a", 4000) + "\"}"; + int len = json.length(); + int split = "{\"id_token\":\"".length() + 1300; // boundary inside the value, past the old 1024 limit + long address = TestUtils.toMemory(json); + try { + try { + parseSplitValue(1024, address, split, len); + Assert.fail("the original 1024-byte cache limit must reject a split multi-KB token value"); + } catch (JsonException expected) { + Assert.assertTrue(expected.getFlyweightMessage().toString(), + expected.getFlyweightMessage().toString().contains("String is too long")); + } + // the sizing OidcDeviceAuth now uses parses the same split value + parseSplitValue(1 << 20, address, split, len); + } finally { + Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + @Test(timeout = 30_000) + public void 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 (String s : loopback) { + Assert.assertTrue("expected loopback: [" + s + "]", invokeIsLoopbackHost(s)); + } + } + + @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 (String s : notLoopback) { + Assert.assertFalse("expected non-loopback: [" + s + "]", invokeIsLoopbackHost(s)); + } + } + + @Test(timeout = 30_000) + public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exception { + // The "127.1" reachability trick below depends on the OS resolver expanding the abbreviated IPv4 + // form to 127.0.0.1 (inet_aton, on Linux/macOS). Windows getaddrinfo - which the native HTTP client + // resolves through - does not accept the short form, so the loopback mock is unreachable there. + Assume.assumeTrue("requires inet_aton-style short-form IPv4 resolution, unavailable on Windows", Os.type != Os.WINDOWS); + assertMemoryLeak(() -> { + // the end-to-end firing path of the plaintext-channel MITM pin, which a 127.0.0.1-bound mock + // cannot otherwise reach: a non-loopback http /settings that advertises BOTH endpoints (so the + // missing-endpoint discovery pin does not apply) must be refused unless the identity provider is + // pinned out of band - otherwise a tampered response could route the device code and refresh token + // to an attacker. Reaching the mock through "127.1" is the trick: the OS resolver expands the short + // form to 127.0.0.1 so the loopback mock answers, but the loopback classifier deliberately rejects + // the short form, so the server host is non-loopback and the pin fires. + AtomicReference serverRef = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + MockOidcServer server = serverRef.get(); + return MockOidcServer.json(200, settingsJson(true, true, server.httpUrl(TOKEN_PATH), server.httpUrl(DEVICE_PATH))); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + serverRef.set(server); + String questdbUrl = "http://127.1:" + server.port(); + // without an out-of-band pin the plaintext channel is untrusted: the pin fires + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(questdbUrl, insecure())) { + Assert.fail("expected the plaintext-channel pin to reject /settings-supplied endpoints without a pin"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("reached over insecure http")); + } + // pinning the issuer to the advertised endpoints' origin satisfies the pin over the very same + // plaintext channel, so construction succeeds - proving the pin, not some unrelated rejection, + // is what gated the unpinned call above + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(questdbUrl, insecure().issuer(server.httpUrl("")))) { + Assert.assertNotNull(auth); + } + } + }); + } + + @Test(timeout = 30_000) + public void testMalformedEndpointDoesNotLeakNativeMemory() { + // build() parses the endpoints up front (for the co-location / issuer-pin checks) and throws on + // this malformed url before the constructor allocates the native JSON lexer, so the never-returned + // instance cannot leak it. Measure the parser tag directly - the module's assertMemoryLeak does not + // flag a single-tag growth. + long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("not-a-url") + .tokenEndpoint("https://idp.example/token") + .allowInsecureTransport(true) + .build() + ) { + Assert.fail("expected Endpoint.parse to reject the malformed url"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("expected a scheme")); + } + 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; signIn() must fail + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson(null, "ID-ONLY", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no access_token")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testNonSuccessDeviceAuthorizationResponseRejected() throws Exception { + assertMemoryLeak(() -> { + // RFC 8628 3.2: a device authorization grant is a 2xx response. A non-2xx body that nonetheless + // carries device_code/user_code/verification_uri and no OAuth error must be rejected - the client + // must not prompt the user and poll on a response the server never signalled success for + MockOidcServer.Handler handler = (method, path, body) -> + MockOidcServer.json(403, deviceAuthorizationJson(1, 300)); + AtomicBoolean prompted = new AtomicBoolean(false); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, challenge -> prompted.set(true))) { + try { + auth.signIn(); + Assert.fail("expected the non-2xx device authorization response to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response from the device authorization endpoint")); + } + Assert.assertFalse("the user must not be prompted on a rejected device authorization response", prompted.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testNullAccessTokenNotServedAsLiteralNull() throws Exception { + assertMemoryLeak(() -> { + // a JSON null arrives from the lexer as the literal "null"; "access_token": null must be treated + // as absent, not stored and served as the 4-char token "null" + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, "{\"token_type\":\"Bearer\",\"expires_in\":3600,\"access_token\":null}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + String token = auth.signIn(); + Assert.fail("a JSON null access_token must not be served as the literal token \"null\" [got=" + token + "]"); + } catch (OidcAuthException e) { + // null is absent, so a 2xx with no token is a definitive but malformed answer + Assert.assertTrue(e.getMessage(), e.getMessage().contains("unexpected response")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testNullJsonErrorIsTreatedAsAbsent() throws Exception { + assertMemoryLeak(() -> { + // "error": null in a device-auth response must be treated as absent, not as an OAuth error whose + // code is the literal string "null"; the flow must proceed to prompt and poll + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"WDJB\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"error\":null," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-OK", auth.signIn()); + } + }); + } + + @Test(timeout = 30_000) + public void testNullPromptDefaultsToSystemOut() throws Exception { + assertMemoryLeak(() -> { + // builder.prompt(null) must fall back to the default prompt rather than NPE during the flow + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-NP", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .prompt(null) + .allowInsecureTransport(true) + .build()) { + // no NPE: the flow runs to completion with the default SYSTEM_OUT prompt + Assert.assertEquals("ACCESS-NP", auth.signIn()); + } + }); + } + + @Test(timeout = 30_000) + public void testOauthErrorMessageStripsBidiControls() throws Exception { + assertMemoryLeak(() -> { + // an IdP error_description carrying a right-to-left override and a zero-width space (as JSON + // unicode escapes the lexer decodes) must not reach the exception message verbatim; they would + // let a malicious IdP reorder or hide text when the message is rendered to a terminal or a log + String desc = "denied" + jsonUnicodeEscape(0x202E) + "reversed" + jsonUnicodeEscape(0x200B) + "end"; + MockOidcServer.Handler handler = (method, path, body) -> + MockOidcServer.json(400, "{\"error\":\"access_denied\",\"error_description\":\"" + desc + "\"}"); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + String msg = e.getMessage(); + assertNoUnsafeDisplayChars(msg); + Assert.assertTrue(msg, msg.contains("access_denied")); + Assert.assertTrue(msg, msg.contains("deniedreversedend")); // readable text survives, controls gone + } + } + }); + } + + @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.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + String msg = e.getMessage(); + assertNoControlChars(msg); + Assert.assertTrue(msg, msg.contains("access_denied")); + Assert.assertTrue(msg, msg.contains("FAKE: paste your token")); // readable text survives + } + } + }); + } + + @Test(timeout = 30_000) + public void testOutOfRangePollIntervalAndExpiryAreClamped() throws Exception { + assertMemoryLeak(() -> { + // a hostile or misconfigured identity provider reports an absurd interval/expires_in; the + // client must clamp both, so interval*1000 cannot overflow into a zero-delay busy loop and + // the wait cannot run absurdly long + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(2_000_000_000, 2_000_000_000)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-CLAMP", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-CLAMP", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + // the absurd interval/expires_in are clamped to the documented maxima: the poll interval to + // MAX_POLL_INTERVAL_SECONDS (60) and the device-code lifetime to MAX_DEVICE_CODE_TTL_SECONDS (1800) + Assert.assertEquals(60, challenge.getIntervalSeconds()); + Assert.assertEquals(1800, challenge.getExpiresInSeconds()); + } + }); + } + + @Test(timeout = 30_000) + public void testOversizedSettingsBodyAbortsAtSizeCap() throws Exception { + assertMemoryLeak(() -> { + // a hostile or MITM'd server streams a /settings body larger than the client's response-size cap + // (MAX_RESPONSE_BODY_BYTES, 4 MiB); the bounded read must abort on the cap rather than consume the + // body without limit. Stream well past the cap - the client stops reading and closes the + // connection once it crosses 4 MiB + MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.oversizedJson(8L * 1024 * 1024); + try (MockOidcServer server = new MockOidcServer(handler)) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected discovery to abort on the response-size cap"); + } catch (OidcAuthException e) { + // the size-cap failure surfaces as the cause; the body (which carries access/id/refresh + // tokens on a real response) is never embedded in the message + Throwable cause = e.getCause(); + Assert.assertNotNull("expected the size-cap failure as the cause", cause); + Assert.assertTrue(cause.getMessage(), cause.getMessage().contains("exceeded the size limit")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testPollAbortDropsDirtyConnectionAndReconnects() throws Exception { + assertMemoryLeak(() -> { + // the token endpoint stalls the body on the first poll, so the bounded read aborts with the + // response half-read and unconsumed bytes left in the cached keep-alive connection. The poll loop + // must drop that connection and reconnect for the next poll, not reuse it: the stalled mock thread + // never reads a reused connection, so reusing it would leave every later poll unanswered until the + // device code expires (and, for a non-stalled dirty connection, would mis-frame the next response + // against this one's leftovers). With the reconnect, the second poll reaches a fresh connection and + // succeeds. Without the fix this test hangs until the 10s device-code lifetime and signIn throws. + AtomicInteger tokenCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + // short lifetime, well under the 30s mock stall and the 30s test timeout, so the no-fix + // failure (poll the dirty connection until expiry) surfaces deterministically and fast + return MockOidcServer.json(200, deviceAuthorizationJson(1, 10)); + } + if (tokenCalls.getAndIncrement() == 0) { + return MockOidcServer.stall(); + } + return MockOidcServer.json(200, tokenJson("ACCESS-RECONNECTED", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .httpTimeoutMillis(1_000) // abort the stalled body read quickly + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + Assert.assertEquals("ACCESS-RECONNECTED", auth.signIn()); + Assert.assertEquals(2, tokenCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testPollIntervalClampedTo60() throws Exception { + assertMemoryLeak(() -> { + // the identity-provider-reported poll interval is capped at 60s (matching the Python client); the + // clamped value is the one shown to the user and used between polls. A short-lived device code + // ends the flow quickly via timeout, once the interval has been captured by the prompt. + AtomicReference shown = new AtomicReference<>(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(999, 2)); + } + return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + try { + auth.signIn(); + Assert.fail("expected the device code to expire"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + } + Assert.assertEquals(60, shown.get().getIntervalSeconds()); + } + }); + } + + @Test(timeout = 30_000) + public void testRateLimited429WithTerminalErrorAbortsImmediately() throws Exception { + assertMemoryLeak(() -> { + // a 429 that ALSO carries a terminal OAuth error must fail fast on the error, not back off and poll + // to the device-code deadline: pollOnce handles the OAuth error before the 429 rate-limit backoff + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 5)); + } + return MockOidcServer.json(429, "{\"error\":\"access_denied\",\"error_description\":\"the user declined\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected the terminal OAuth error to abort despite the 429 status"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("device code expired")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testRateLimitedTokenEndpointBacksOffInsteadOfFailingFast() throws Exception { + assertMemoryLeak(() -> { + // HTTP 429 is a transient backoff (poll slower, keep polling), matching the Python client, not a + // terminal rejection. The token endpoint always returns 429, so the flow ends only when the + // short-lived device code expires - proving polling continued rather than failing fast. + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 4)); + } + return MockOidcServer.json(429, "{}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected the device code to expire while the token endpoint kept returning 429"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testPersistentTransportFailureKeepsPollingToDeadline() throws Exception { + assertMemoryLeak(() -> { + // the device endpoint works, but the (co-located) token endpoint drops the connection on every + // poll. Matching the Python client, a transport failure is transient - the user may already have + // authorized - so polling continues until the device code expires rather than failing fast. The + // endpoints share one origin so the build-time co-location check passes; the mock simulates the + // unreachable token endpoint by dropping the connection. + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 3)); + } + return MockOidcServer.dropConnection(); + }; + try (MockOidcServer server = new MockOidcServer(handler)) { + try (OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + auth.signIn(); + Assert.fail("expected the device code to expire while the token endpoint kept dropping"); + } catch (OidcAuthException e) { + // polled to the deadline (device code expired), not a fast transport abort + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("unreachable")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testPersistent5xxDuringPollingKeepsPollingToDeadline() throws Exception { + assertMemoryLeak(() -> { + // a 5xx from the token endpoint is a transient server/gateway condition: keep polling to the + // device-code deadline rather than failing fast, matching the Python client + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 3)); + } + return MockOidcServer.json(503, "{}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected the device code to expire while the token endpoint returned 503"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device code expired")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("rejected the request")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTerminal4xxDuringPollingFailsFast() throws Exception { + assertMemoryLeak(() -> { + // a 4xx from the token endpoint with no OAuth error (e.g. a WAF or proxy rejection) is terminal: + // fail fast rather than poll on to a misleading "device code expired", matching the Python client + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(403, "{}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a terminal 4xx to fail fast"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("device code expired")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testRefreshErrorFallsBackToInteractiveFlow() throws Exception { + assertMemoryLeak(() -> { + // the cached token expires and the refresh is rejected (revoked/expired refresh token); + // the client must fall back to a fresh interactive sign-in + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger deviceCodeGrants = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + return MockOidcServer.json(400, "{\"error\":\"invalid_grant\"}"); + } + if (deviceCodeGrants.getAndIncrement() == 0) { + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); + // the refresh is rejected, so the flow re-runs the interactive sign-in + Assert.assertEquals("ACCESS-2", auth.signIn()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testRefreshKeepsExistingRefreshTokenWhenOmitted() throws Exception { + assertMemoryLeak(() -> { + // a refresh response that omits refresh_token (RFC 6749 permits this) must not drop the existing + // refresh token; a later refresh must reuse it rather than fall back to a fresh interactive sign-in + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger refreshCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + // every refresh must present the ORIGINAL refresh token, and returns a short-lived + // access token WITHOUT a new refresh_token + Assert.assertTrue(body, body.contains("refresh_token=REFRESH-1")); + int n = refreshCalls.incrementAndGet(); + return MockOidcServer.json(200, tokenJson("ACCESS-R" + n, null, null, 1)); + } + // the initial device-code grant: a short-lived access token plus the refresh token + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); + // first refresh omits refresh_token, so REFRESH-1 must be kept + Assert.assertEquals("ACCESS-R1", auth.signIn()); + expireCachedToken(auth); + // second refresh must still present the retained REFRESH-1 (asserted in the handler) + Assert.assertEquals("ACCESS-R2", auth.signIn()); + Assert.assertEquals("no extra interactive sign-in", 1, deviceCalls.get()); + Assert.assertEquals(2, refreshCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testRefreshTokenAlongsideErrorFallsBackToInteractiveFlow() throws Exception { + assertMemoryLeak(() -> { + // a refresh response that carries an OAuth error (under a non-2xx status) must not be trusted + // even if it also returns a token; the client ignores the smuggled token and falls back to a + // fresh interactive sign-in rather than caching it + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger deviceCodeGrants = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + // malformed: a 400 error together with a token + return MockOidcServer.json(400, "{\"error\":\"invalid_grant\",\"access_token\":\"SHOULD-NOT-BE-USED\"}"); + } + if (deviceCodeGrants.getAndIncrement() == 0) { + return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", null, "REFRESH-2", 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); + // the refresh carries an error+token, so the client must ignore the smuggled token and + // re-run the interactive flow + Assert.assertEquals("ACCESS-2", auth.signIn()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testRefreshWithoutIdTokenFallsBackToInteractiveFlow() throws Exception { + assertMemoryLeak(() -> { + // groups are encoded in the token (the default enterprise config), so signIn() serves the + // id token. The cached token expires and the refresh response omits id_token (RFC 6749 makes + // it optional on refresh), so the client must re-run the interactive flow rather than fail. + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger deviceCodeGrants = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + // a refresh that returns a fresh access token but no id_token + return MockOidcServer.json(200, tokenJson("ACCESS-R", null, null, 3600)); + } + // the device-code grant: first a soon-expired token, then (after fallback) a fresh one + if (deviceCodeGrants.getAndIncrement() == 0) { + return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 1)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", "REFRESH-2", 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { + Assert.assertEquals("ID-1", auth.signIn()); + expireCachedToken(auth); + // the refresh returns no id_token, so the flow falls back to interactive sign-in and + // returns the fresh id token instead of throwing "returned no id_token" + Assert.assertEquals("ID-2", auth.signIn()); + Assert.assertEquals("the interactive flow must run twice (initial + fallback)", 2, deviceCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testServerErrorDuringPollingRetries() throws Exception { + assertMemoryLeak(() -> { + // the token endpoint returns a gateway 5xx with an empty body once (no JSON error), then a + // token. An empty-bodied upstream blip must be retried, not aborted as an "unexpected response" + AtomicInteger tokenCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (tokenCalls.getAndIncrement() == 0) { + return MockOidcServer.json(502, ""); + } + return MockOidcServer.json(200, tokenJson("ACCESS-RECOVERED-5XX", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-RECOVERED-5XX", auth.signIn()); + Assert.assertEquals(2, tokenCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testSilentRefreshWhenTokenExpired() throws Exception { + assertMemoryLeak(() -> { + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger promptCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (body.contains("grant_type=refresh_token")) { + Assert.assertTrue(body, body.contains("refresh_token=REFRESH-1")); + return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", null, 3600)); + } + // initial device-code grant, hand out a token that is already expired vs the clock skew + return MockOidcServer.json(200, tokenJson("ACCESS-1", "ID-1", "REFRESH-1", 1)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, ch -> promptCalls.incrementAndGet())) { + Assert.assertEquals("ACCESS-1", auth.signIn()); + expireCachedToken(auth); + // the cached token is expired, so the second call refreshes silently + Assert.assertEquals("ACCESS-2", auth.signIn()); + Assert.assertEquals("the interactive flow must run only once", 1, deviceCalls.get()); + Assert.assertEquals("the user must be prompted only once", 1, promptCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testSlowDownIncreasesIntervalAndSucceeds() throws Exception { + assertMemoryLeak(() -> { + AtomicInteger tokenCalls = new AtomicInteger(); + AtomicLong firstPollNanos = new AtomicLong(); + AtomicLong secondPollNanos = new AtomicLong(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + int call = tokenCalls.getAndIncrement(); + if (call == 0) { + firstPollNanos.set(System.nanoTime()); + return MockOidcServer.json(400, "{\"error\":\"slow_down\"}"); + } + if (call == 1) { + secondPollNanos.set(System.nanoTime()); + } + return MockOidcServer.json(200, tokenJson("ACCESS-S", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-S", auth.signIn()); + Assert.assertEquals(2, tokenCalls.get()); + // base interval is 1s; the slow_down must add ~5s, so the SECOND poll lands ~6s after + // the first. Assert the inter-poll gap directly, not just total elapsed - without the + // increment the gap would be ~1s. + long gapMillis = (secondPollNanos.get() - firstPollNanos.get()) / 1_000_000L; + Assert.assertTrue("inter-poll gap=" + gapMillis + "ms", gapMillis >= 4_000); + } + }); + } + + @Test(timeout = 30_000) + public void testStalledResponseBodyAbortsWithinTimeout() throws Exception { + assertMemoryLeak(() -> { + // a server that sends headers then stalls the body must not wedge the thread on the 10-minute + // HttpClient default timeout; the body read aborts on the configured OIDC timeout instead + MockOidcServer.Handler handler = (method, path, body) -> MockOidcServer.stall(); + try (MockOidcServer server = new MockOidcServer(handler)) { + long startNanos = System.nanoTime(); + try (OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .httpTimeoutMillis(1_000) + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + auth.signIn(); + Assert.fail("expected the stalled body read to abort"); + } catch (OidcAuthException e) { + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + // aborted on the configured ~1s OIDC timeout: not instantly (which would be a different + // failure path) and not on the 600s HttpClient default (or an indefinite wedge). The window + // proves the 1s timeout fired, with generous headroom for a slow CI host + Assert.assertTrue("aborted too fast to be the 1s timeout: " + elapsedMillis + "ms", elapsedMillis >= 500); + Assert.assertTrue("aborted too slowly for the 1s timeout: " + elapsedMillis + "ms", elapsedMillis < 5_000); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTimesOutWhenCodeExpires() throws Exception { + assertMemoryLeak(() -> { + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + // very short lifetime so the poll loop gives up quickly + return MockOidcServer.json(200, deviceAuthorizationJson(1, 1)); + } + return MockOidcServer.json(400, "{\"error\":\"authorization_pending\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a timeout"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTokenAlongsideOauthErrorIsRejected() throws Exception { + assertMemoryLeak(() -> { + // RFC 6749 5.2: an error response must not be treated as a grant even if the body also carries + // a token. A hostile or buggy IdP returns access_denied together with an access_token; the + // client must surface the error, not cache the smuggled token + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(400, "{\"error\":\"access_denied\",\"access_token\":\"SHOULD-NOT-BE-USED\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected the error response to be rejected, not the smuggled token accepted"); + } catch (OidcAuthException e) { + Assert.assertEquals("access_denied", e.getOauthError()); + Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTokenCachedAcrossCalls() throws Exception { + assertMemoryLeak(() -> { + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger tokenCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + tokenCalls.incrementAndGet(); + return MockOidcServer.json(200, tokenJson("ACCESS-C", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-C", auth.signIn()); + Assert.assertEquals("ACCESS-C", auth.signIn()); + Assert.assertEquals("ACCESS-C", auth.signIn()); + Assert.assertEquals("the interactive flow must run only once", 1, deviceCalls.get()); + Assert.assertEquals("the token endpoint must be hit only once", 1, tokenCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testTokenEndpointErrorDoesNotLeakSecretsInMessage() throws Exception { + assertMemoryLeak(() -> { + final String secret = "SUPER-SECRET-TOKEN-VALUE-0123456789"; + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + // a 4xx (terminal) carrying a token but malformed JSON: the parser fails, and the raw body + // (with the token) must NOT be echoed into the exception message + return MockOidcServer.json(400, "{\"access_token\":\"" + secret + "\" not-valid-json}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertFalse("the token must not leak into the message: " + e.getMessage(), + e.getMessage().contains(secret)); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpStatus=")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTokenResponseExpiresInIsClamped() throws Exception { + assertMemoryLeak(() -> { + // an absurd token-response expires_in (here Integer.MAX_VALUE, ~68 years) must be clamped to + // MAX_EXPIRES_IN_SECONDS (1h) like the device-side value, so the client does not trust a stale + // cached token for decades (the server still enforces the real expiry). + AtomicInteger deviceCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + // no refresh_token, so an expired cache forces a fresh device flow rather than a silent refresh + return MockOidcServer.json(200, tokenJson("ACCESS-OK", null, null, Integer.MAX_VALUE)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .scope("openid") + .prompt(noopPrompt()) + .allowInsecureTransport(true) + .build()) { + long before = System.currentTimeMillis(); + Assert.assertEquals("ACCESS-OK", auth.signIn()); + long after = System.currentTimeMillis(); + Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get()); + + // the cached expiry must be ~1h out (the clamp), not ~68 years + long maxLifetimeMillis = 3600L * 1000L; + long expiresAt = readExpiresAtMillis(auth); + Assert.assertTrue("expiry must be clamped to <= 1h ahead, was " + (expiresAt - before) + "ms ahead", + expiresAt <= after + maxLifetimeMillis); + Assert.assertTrue("expiry must be ~1h ahead (the clamp), was " + (expiresAt - after) + "ms ahead", + expiresAt >= before + maxLifetimeMillis - 5_000L); + + // once the clamped token is past expiry, with no refresh token signIn() re-runs the device flow + expireCachedToken(auth); + Assert.assertEquals("ACCESS-OK", auth.signIn()); + Assert.assertEquals("expired clamped token forces a fresh sign-in", 2, deviceCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testTokenResponseExpiresInZeroUsesDefaultTtl() throws Exception { + assertMemoryLeak(() -> { + // a token response with a non-positive expires_in (here 0) must fall back to + // DEFAULT_TOKEN_TTL_SECONDS (5 min), not be treated as already-expired or cached forever. + // testTokenResponseExpiresInIsClamped covers the absurd-large end; this covers the <= 0 default. + AtomicInteger deviceCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + // no refresh_token, so an expired cache forces a fresh device flow rather than a silent refresh + return MockOidcServer.json(200, tokenJson("ACCESS-DEF", null, null, 0)); // expires_in = 0 + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + long before = System.currentTimeMillis(); + Assert.assertEquals("ACCESS-DEF", auth.signIn()); + long after = System.currentTimeMillis(); + Assert.assertEquals("first sign-in runs the device flow once", 1, deviceCalls.get()); + + // the cached expiry must be ~5min out (the default), neither ~now (treated as expired) nor far + long defaultTtlMillis = 300L * 1000L; + long expiresAt = readExpiresAtMillis(auth); + Assert.assertTrue("expiry must be ~5min ahead (the default), was " + (expiresAt - after) + "ms ahead", + expiresAt >= before + defaultTtlMillis - 5_000L); + Assert.assertTrue("expiry must be ~5min ahead (the default), not longer, was " + (expiresAt - before) + "ms ahead", + expiresAt <= after + defaultTtlMillis); + } + }); + } + + @Test(timeout = 30_000) + public void testTokenUnderNonSuccessStatusIsNotAccepted() throws Exception { + assertMemoryLeak(() -> { + // RFC 6749 5.1: a token must come from a 2xx response. A token under a non-2xx status with no + // OAuth error is a malformed or hostile answer; the client must not cache it - a 4xx is a + // terminal rejection that fails fast rather than trusting the token + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(400, "{\"access_token\":\"SHOULD-NOT-BE-USED\"}"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a token under a 400 to be rejected, not accepted"); + } catch (OidcAuthException e) { + Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-BE-USED")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("rejected the request")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTokenWithControlCharsRejected() throws Exception { + assertMemoryLeak(() -> { + // a hostile or man-in-the-middled identity provider returns an access token whose JSON value carries + // an escaped CR/LF; the lexer decodes it to real control bytes, which - sent verbatim in the + // Authorization header to the trusted QuestDB server - would inject into the request line. storeTokens + // must reject the token rather than cache and serve it, and must not leak the token into the message + String injected = "header.payload" + jsonUnicodeEscape(0x0d) + jsonUnicodeEscape(0x0a) + "X-Injected:1"; + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson(injected, null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a token with control characters to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); + // the token bytes must never leak into the message + Assert.assertFalse(e.getMessage(), e.getMessage().contains("X-Injected")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTokenWithNonAsciiCharRejected() throws Exception { + assertMemoryLeak(() -> { + // the > 0x7e arm of the token guard (testTokenWithControlCharsRejected covers the < 0x20 arm): + // a non-ASCII char (here U+00E9, not a control char) in the access token would be silently + // truncated to one byte by the ASCII Authorization-header writer, yielding a corrupt credential. + // storeTokens must reject it, and must not leak the token into the message + String injected = "header.payload" + jsonUnicodeEscape(0x00e9) + "SHOULD-NOT-LEAK"; // e-acute, > 0x7e + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(200, tokenJson(injected, null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a token with a non-ASCII character to be rejected"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("disallowed control or non-ASCII")); + // the token bytes must never leak into the message + Assert.assertFalse(e.getMessage(), e.getMessage().contains("SHOULD-NOT-LEAK")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTransientParseFailureDuringPollingRecovers() throws Exception { + assertMemoryLeak(() -> { + // the token endpoint returns a garbled (non-JSON) body once, then a valid token; a transient + // parse failure is retried like a transport blip rather than aborting the sign-in + AtomicInteger tokenCalls = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + if (tokenCalls.getAndIncrement() == 0) { + return MockOidcServer.json(200, "502 Bad Gateway"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-RECOVERED", null, null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("ACCESS-RECOVERED", auth.signIn()); + Assert.assertEquals(2, tokenCalls.get()); + } + }); + } + + @Test(timeout = 30_000) + public void testTruncatedSettingsResponseRejected() throws Exception { + assertMemoryLeak(() -> { + // the /settings body is cut off mid-object (HTTP framing satisfied, JSON unterminated). discovery + // must reject it as a parse failure, not silently discover from the partial document and report a + // misleading "does not advertise ..." error + MockOidcServer.Handler handler = (method, path, body) -> + MockOidcServer.json(200, "{\"config\":{\"acl.oidc.enabled\":true,\"acl.oidc.client.id\":\"questdb\""); + try (MockOidcServer server = new MockOidcServer(handler)) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure())) { + Assert.fail("expected discovery to reject the truncated settings body"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("could not parse")); + } + } + }); + } + + @Test(timeout = 30_000) + public void testTruncatedTokenResponseRejected() throws Exception { + assertMemoryLeak(() -> { + // a token response whose Content-Length is satisfied but whose JSON is unterminated must be + // rejected (parseLast catches the dangling value), not silently treated as no token. A 4xx makes + // the parse failure terminal so it surfaces immediately (a malformed 2xx is retried as transient). + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + return MockOidcServer.json(400, "{\"access_token\":\"abc"); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + 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.signIn(); + 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 (signIn's + // documented failure type), not a raw HttpClientException + int deadPort; + try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + deadPort = probe.getLocalPort(); + } // closed now - nothing listens on deadPort + try (OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("questdb") + .deviceAuthorizationEndpoint("http://127.0.0.1:" + deadPort + "/device") + .tokenEndpoint("http://127.0.0.1:" + deadPort + "/token") + .allowInsecureTransport(true) + .prompt(noopPrompt()) + .build()) { + auth.signIn(); + Assert.fail("expected an OidcAuthException"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("device authorization endpoint")); + } + }); + } + + @Test(timeout = 30_000) + public void testUseAfterCloseThrowsClearly() { + // calling signIn()/clearCache() after close() must fail with a clear "closed" error rather than + // NPE on the freed JSON lexer or resurrect (and leak) a fresh native HTTP client + long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS); + // close() is the subject under test, so it is called explicitly mid-body; the try-with-resources + // close at scope exit is a harmless idempotent second close that also covers an early assertion throw + try (OidcDeviceAuth auth = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint("https://idp.example/device") + .tokenEndpoint("https://idp.example/token") + .build() + ) { + auth.close(); + try { + auth.signIn(); + Assert.fail("expected signIn() 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")); + } + // signIn() must reject before resurrecting a native HTTP client, and close() must have freed + // the JSON lexer, so the parser-tag memory returns to its pre-construction level + Assert.assertEquals("a closed instance must not leak or resurrect native memory", + parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS)); + } + } + + @Test(timeout = 30_000) + public void testVerificationUrlAliasesParsed() throws Exception { + assertMemoryLeak(() -> { + // some identity providers (historically Google) return verification_url / verification_url_complete + // instead of the RFC 8628 verification_uri / verification_uri_complete; both spellings must populate + // the challenge shown to the user + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV-CODE\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_url\":\"https://verify.example/device\"," + + "\"verification_url_complete\":\"https://verify.example/device?user_code=WDJB-MJHT\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.json(200, tokenJson("ACCESS-ALIAS", null, null, 3600)); + }; + AtomicReference shown = new AtomicReference<>(); + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, shown::set)) { + Assert.assertEquals("ACCESS-ALIAS", auth.signIn()); + DeviceAuthorizationChallenge challenge = shown.get(); + Assert.assertNotNull(challenge); + Assert.assertEquals("https://verify.example/device", challenge.getVerificationUri()); + Assert.assertEquals("https://verify.example/device?user_code=WDJB-MJHT", challenge.getVerificationUriComplete()); + } + }); + } + + @Test(timeout = 30_000) + public void testWrongTokenKindDoesNotWedgeCache() throws Exception { + assertMemoryLeak(() -> { + // groups-in-token mode, but the IdP returns only an access token on the first grant (e.g. the + // requested scope omitted openid). signIn() must fail the first call, then re-run the + // interactive flow on the next call - not cache the unusable access token as valid and keep + // throwing "no id_token" on every later call + AtomicInteger deviceCalls = new AtomicInteger(); + AtomicInteger deviceCodeGrants = new AtomicInteger(); + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + deviceCalls.incrementAndGet(); + return MockOidcServer.json(200, deviceAuthorizationJson(1, 300)); + } + // first grant: access token only (no id_token); second grant: a proper id token + if (deviceCodeGrants.getAndIncrement() == 0) { + return MockOidcServer.json(200, tokenJson("ACCESS-ONLY", null, null, 3600)); + } + return MockOidcServer.json(200, tokenJson("ACCESS-2", "ID-2", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, true, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected an OidcAuthException on the first call"); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no id_token")); + } + // the unusable grant must NOT be cached as valid: the next call re-runs the flow and succeeds + Assert.assertEquals("ID-2", auth.signIn()); + Assert.assertEquals("the interactive flow must run twice (failed first, recovered second)", 2, deviceCalls.get()); + } + }); + } + + private static void assertBuildFails(String deviceEndpoint, String tokenEndpoint, String expectedMessage) { + try (OidcDeviceAuth ignored = OidcDeviceAuth.builder() + .clientId("c") + .deviceAuthorizationEndpoint(deviceEndpoint) + .tokenEndpoint(tokenEndpoint) + .build() + ) { + Assert.fail("expected build to fail for device=" + deviceEndpoint + " token=" + tokenEndpoint); + } catch (OidcAuthException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); + } + } + + private static void assertNoControlChars(String value) { + for (int i = 0; i < value.length(); i++) { + Assert.assertFalse("control char at index " + i + " in '" + value + "'", Character.isISOControl(value.charAt(i))); + } + } + + private static void assertNoUnsafeDisplayChars(String value) { + // mirrors OidcAuthException.isUnsafeForDisplay: no controls, no Cf format chars, no bidi/BOM - + // checked per code point so a supplementary-plane (>= U+10000) format/control char is not missed + for (int i = 0; i < value.length(); ) { + int cp = value.codePointAt(i); + boolean unsafe = Character.isISOControl(cp) + || Character.getType(cp) == Character.FORMAT + || Character.getType(cp) == Character.SURROGATE + || (cp >= 0x202A && cp <= 0x202E) + || (cp >= 0x2066 && cp <= 0x2069) + || cp == 0x200E || cp == 0x200F + || cp == 0xFEFF; + Assert.assertFalse("display-unsafe char U+" + Integer.toHexString(cp) + " at index " + i + " in '" + value + "'", unsafe); + i += Character.charCount(cp); + } + } + + private static String deviceAuthorizationJson(int interval, int expiresIn) { + return "{" + + "\"device_code\":\"DEV-CODE\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"verification_uri_complete\":\"https://verify.example/device?user_code=WDJB-MJHT\"," + + "\"expires_in\":" + expiresIn + "," + + "\"interval\":" + interval + + "}"; + } + + // DiscoveryOptions permitting insecure http with a no-op prompt: tests must never print to the console + // or try to open a real browser, which the default prompt now does. The common shape for tests reaching + // a plaintext mock server. + private static OidcDeviceAuth.DiscoveryOptions insecure() { + return new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true).prompt(noopPrompt()); + } + + @Test(timeout = 30_000) + public void testControlCharInUnusedTokenKindDoesNotAbortGrant() throws Exception { + assertMemoryLeak(() -> { + // groupsInToken=false, so signIn() serves and sends only the access_token; the id_token is + // cached but never placed in a header or a PG-wire password. A control char in that unused id_token + // must not reject an otherwise-usable grant - only the served kind is validated for wire safety + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + // a clean access_token (the served kind) alongside an id_token carrying a decoded control char + return MockOidcServer.json(200, tokenJson("CLEAN-ACCESS", "bad" + jsonUnicodeEscape(0x0001) + "id", null, 3600)); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + Assert.assertEquals("CLEAN-ACCESS", auth.signIn()); + } + }); + } + + @Test(timeout = 30_000) + public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception { + assertMemoryLeak(() -> { + // a real HTTP status is exactly 3 digits; a malformed 1-digit "2" (all digits, so readResponse + // accepts it) must not be classified as a 2xx success by its leading digit and accepted as a grant + String tokenBody = tokenJson("SHOULD-NOT-ACCEPT", null, null, 3600); + String rawToken = "HTTP/1.1 2 OK\r\n" + + "Content-Type: application/json\r\n" + + "Transfer-Encoding: chunked\r\n\r\n" + + Integer.toHexString(tokenBody.length()) + "\r\n" + tokenBody + "\r\n" + + "0\r\n\r\n"; + MockOidcServer.Handler handler = (method, path, body) -> { + if (DEVICE_PATH.equals(path)) { + return MockOidcServer.json(200, "{" + + "\"device_code\":\"DEV\"," + + "\"user_code\":\"WDJB-MJHT\"," + + "\"verification_uri\":\"https://verify.example/device\"," + + "\"expires_in\":300," + + "\"interval\":1" + + "}"); + } + return MockOidcServer.raw(rawToken); + }; + try (MockOidcServer server = new MockOidcServer(handler); + OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) { + try { + auth.signIn(); + Assert.fail("expected a malformed 1-digit status to be rejected, not accepted as success"); + } catch (OidcAuthException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("rejected the request") || msg.contains("refusing to keep polling")); + Assert.assertFalse("the unaccepted token must not leak: " + msg, msg.contains("SHOULD-NOT-ACCEPT")); + } + } + }); + } + + // Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next + // signIn()/getToken() takes the silent-refresh (or interactive re-sign-in) path. Reflection + // because the field is private and there is no configurable clock skew to lean on anymore; the client is + // an open module, so this reaches it without widening production visibility for the test. + private static void expireCachedToken(OidcDeviceAuth auth) throws Exception { + Field f = OidcDeviceAuth.class.getDeclaredField("expiresAtMillis"); + f.setAccessible(true); + f.setLong(auth, 0L); // any "now" is past 0 minus the (capped, non-negative) skew, so the token reads as expired + } + + // Reads the cached token's absolute expiry (epoch millis) so a test can assert the lifetime clamp directly. + private static long readExpiresAtMillis(OidcDeviceAuth auth) throws Exception { + Field f = OidcDeviceAuth.class.getDeclaredField("expiresAtMillis"); + f.setAccessible(true); + return f.getLong(auth); + } + + // isEndpointUnderIssuerPath is a private static security check (it scopes a /settings-advertised endpoint + // to the pinned issuer's path); the client is an open module, so reflection reaches it without widening + // production visibility for the test + private static boolean invokeIsEndpointUnderIssuerPath(String endpointUrl, String issuer) throws Exception { + Method m = OidcDeviceAuth.class.getDeclaredMethod("isEndpointUnderIssuerPath", String.class, String.class); + m.setAccessible(true); + return (boolean) m.invoke(null, endpointUrl, issuer); + } + + // isLoopbackHost is a private static security classifier (it gates the plaintext-channel MITM pin); the + // client is an open module, so reflection reaches it without widening production visibility for the test + private static boolean invokeIsLoopbackHost(String host) throws Exception { + 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 + 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") + .deviceAuthorizationEndpoint(server.httpUrl(DEVICE_PATH)) + .tokenEndpoint(server.httpUrl(TOKEN_PATH)) + .scope("openid groups") + .groupsInToken(groupsInToken) + .prompt(prompt) + .allowInsecureTransport(true) + .build(); + } + + private static DeviceCodePrompt noopPrompt() { + return challenge -> { + }; + } + + private static void parseSplitValue(int cacheSizeLimit, long address, int split, int len) throws JsonException { + try (JsonLexer lexer = new JsonLexer(1024, cacheSizeLimit)) { + lexer.parse(address, address + split, NOOP_JSON_PARSER); + lexer.parse(address + split, address + len, NOOP_JSON_PARSER); + lexer.parseLast(); + } + } + + private static String settingsJson(boolean enabled, boolean withDeviceEndpoint, String tokenEndpoint, String deviceEndpoint) { + StringSink config = new StringSink(); + config.put("{\"config\":{"); + config.put("\"acl.oidc.enabled\":").put(Boolean.toString(enabled)).put(','); + config.put("\"acl.oidc.client.id\":\"questdb\","); + config.put("\"acl.oidc.scope\":\"openid groups\","); + config.put("\"acl.oidc.groups.encoded.in.token\":true,"); + config.put("\"acl.oidc.token.endpoint\":\"").put(tokenEndpoint).put('"'); + if (withDeviceEndpoint) { + config.put(",\"acl.oidc.device.authorization.endpoint\":\"").put(deviceEndpoint).put('"'); + } + config.put("},\"preferences.version\":0,\"preferences\":{}}"); + return config.toString(); + } + + private static String tokenJson(String accessToken, String idToken, String refreshToken, int expiresIn) { + StringSink sb = new StringSink(); + sb.put("{\"token_type\":\"Bearer\",\"expires_in\":").put(expiresIn); + if (accessToken != null) { + sb.put(",\"access_token\":\"").put(accessToken).put('"'); + } + if (idToken != null) { + sb.put(",\"id_token\":\"").put(idToken).put('"'); + } + if (refreshToken != null) { + sb.put(",\"refresh_token\":\"").put(refreshToken).put('"'); + } + sb.put('}'); + return sb.toString(); + } + + private static String wellKnownJson(String deviceEndpoint, String tokenEndpoint, String issuer) { + return "{" + + "\"issuer\":\"" + issuer + "\"," + + "\"authorization_endpoint\":\"" + issuer + "/authorize\"," + + "\"token_endpoint\":\"" + tokenEndpoint + "\"," + + "\"device_authorization_endpoint\":\"" + deviceEndpoint + "\"" + + "}"; + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java index 6bd20a94..2a80a958 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ChunkedResponseTest.java @@ -26,6 +26,7 @@ import io.questdb.client.cutlass.http.client.AbstractChunkedResponse; import io.questdb.client.cutlass.http.client.Fragment; +import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Numbers; import io.questdb.client.std.ObjList; @@ -186,6 +187,69 @@ public void testFuzz() { createChunks(rnd, encoded.toString(), fragCount)); } + @Test(timeout = 30_000) + public void testNoArgRecvHonoursPositiveDefaultTimeout() { + // The ILP flush path reads a chunked response via the no-arg recv(), which delegates to + // recv(defaultTimeout). With a positive defaultTimeout (the production HttpClient timeout) the + // whole-call bound applies on that path too, so a server dribbling a never-terminated chunk size + // cannot wedge a single recv() past the timeout. The explicit recv(int) path is covered by + // testRecvHonoursTotalTimeoutWhileChunkSizeDribbles. + final long memSize = 64; + final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT); + try { + final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, 50) { // positive default + @Override + protected int recvOrDie(long bufLo, long bufHi, int timeout) { + if (bufLo >= bufHi) { + return 0; // buffer full of a CRLF-less chunk size: no forward progress + } + Unsafe.getUnsafe().putByte(bufLo, (byte) '0'); // a hex digit, never the terminating CR + return 1; + } + }; + rsp.begin(mem, mem); + try { + rsp.recv(); // no-arg: delegates to recv(defaultTimeout=50) + Assert.fail("expected the no-arg recv to time out on a dribbled, never-terminated chunk size"); + } catch (HttpClientException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT); + } + } + + @Test(timeout = 30_000) + public void testRecvHonoursTotalTimeoutWhileChunkSizeDribbles() { + // a server that dribbles the chunk-size line and never sends its terminating CRLF must not keep a + // single recv() running past its timeout. recv(timeout) bounds the whole call (not each socket read), + // so the loop scanning the never-terminated chunk size aborts once the timeout elapses. Without the + // bound this recv() never returns and the @Test timeout fires instead. + final long memSize = 64; + final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT); + try { + final AbstractChunkedResponse rsp = new AbstractChunkedResponse(mem, mem + memSize, -1) { + @Override + protected int recvOrDie(long bufLo, long bufHi, int timeout) { + if (bufLo >= bufHi) { + return 0; // buffer full of a CRLF-less chunk size: no forward progress + } + Unsafe.getUnsafe().putByte(bufLo, (byte) '0'); // a hex digit, never the terminating CR + return 1; + } + }; + rsp.begin(mem, mem); + try { + rsp.recv(50); + Assert.fail("expected recv to time out on a dribbled, never-terminated chunk size"); + } catch (HttpClientException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT); + } + } + @Test public void testSingleFragment() { String[] fragments = { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java index 6c9901db..2867c27d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/ResponseTest.java @@ -27,6 +27,7 @@ import io.questdb.client.cutlass.http.client.AbstractResponse; import io.questdb.client.cutlass.http.client.Fragment; +import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Os; import io.questdb.client.std.Unsafe; @@ -37,6 +38,34 @@ public class ResponseTest { + @Test(timeout = 30_000) + public void testNoArgRecvHonoursPositiveDefaultTimeout() { + // The ILP flush path reads via the no-arg recv(), which delegates to recv(defaultTimeout). With a + // positive defaultTimeout (the production HttpClient timeout) the whole-call bound applies on that + // path too, so a server yielding no application bytes cannot wedge a single recv() past the timeout. + // The explicit recv(int) path is covered by testRecvHonoursTotalTimeoutWhenNoApplicationBytesArrive. + final long memSize = 64; + final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT); + try { + final AbstractResponse rsp = new AbstractResponse(mem, mem + memSize, 50) { // positive defaultTimeout + @Override + protected int recvOrDie(long bufLo, long bufHi, int timeout) { + Os.sleep(1); // a readability wakeup that decrypts to no application bytes + return 0; + } + }; + rsp.begin(mem, mem, 16); // content length 16, nothing received yet + try { + rsp.recv(); // no-arg: delegates to recv(defaultTimeout=50) + Assert.fail("expected the no-arg recv to time out under a positive default timeout"); + } catch (HttpClientException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT); + } + } + @Test public void testNoSplit() { String[] expectedFragments = { @@ -48,6 +77,36 @@ public void testNoSplit() { assertResponse(expectedFragments, actualFragments); } + @Test(timeout = 30_000) + public void testRecvHonoursTotalTimeoutWhenNoApplicationBytesArrive() { + // A Content-Length response whose socket reads yield no application bytes - e.g. an incomplete or + // empty TLS record over a hostile or MITM'd link, where JavaTlsClientSocket.recv returns 0 on + // BUFFER_UNDERFLOW without a disconnect - must not keep a single recv() running past its timeout. + // recv(timeout) bounds the whole call, not each socket read, so the while (len == 0) loop aborts + // once the timeout elapses. Without the bound this recv() never returns and the @Test timeout + // fires instead. + final long memSize = 64; + final long mem = Unsafe.malloc(memSize, MemoryTag.NATIVE_DEFAULT); + try { + final AbstractResponse rsp = new AbstractResponse(mem, mem + memSize, -1) { + @Override + protected int recvOrDie(long bufLo, long bufHi, int timeout) { + Os.sleep(1); // a readability wakeup that decrypts to no application bytes + return 0; + } + }; + rsp.begin(mem, mem, 16); // content length 16, nothing received yet + try { + rsp.recv(50); + Assert.fail("expected recv to time out when no application bytes arrive"); + } catch (HttpClientException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + Unsafe.free(mem, memSize, MemoryTag.NATIVE_DEFAULT); + } + } + @Test public void testSplit1() { String[] expectedFragments = { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java index 2e8cd96e..74d8d9d5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java @@ -24,14 +24,17 @@ package io.questdb.client.test.cutlass.json; +import io.questdb.client.Sender; import io.questdb.client.cutlass.json.JsonException; import io.questdb.client.cutlass.json.JsonLexer; import io.questdb.client.cutlass.json.JsonParser; +import io.questdb.client.cutlass.line.http.AbstractLineHttpSender; import io.questdb.client.std.Files; import io.questdb.client.std.IntStack; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Mutable; import io.questdb.client.std.Unsafe; +import io.questdb.client.std.str.StringSink; import io.questdb.client.test.tools.TestUtils; import org.junit.AfterClass; import org.junit.Assert; @@ -243,7 +246,9 @@ public void testNestedObjects() throws Exception { @Test public void testQuoteEscape() throws Exception { - assertThat("{\"x\":\"a\\\"bc\"}", "{\"x\": \"a\\\"bc\"}"); + // the lexer decodes the escaped quote: the value a\"bc becomes a"bc (the assembling parser does + // not re-escape, so the decoded quote shows bare in the re-serialized form) + assertThat("{\"x\":\"a\"bc\"}", "{\"x\": \"a\\\"bc\"}"); } @Test @@ -663,6 +668,145 @@ 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) + }); + } + + @Test + public void testStringEscapesDecodedAcrossSplitParseCalls() throws Exception { + assertMemoryLeak(() -> { + // a value whose backslash escape straddles two parse() calls (a real HTTP-fragment boundary) + // must still be decoded: the "saw a backslash" flag that gates the unescape pass has to persist + // across the calls, not reset to false at the start of the second one + String json = "{\"v\":\"ab\\ncd\"}"; // value ab\ncd -> abcd + int len = json.length(); + long address = TestUtils.toMemory(json); + StringSink captured = new StringSink(); + JsonParser parser = (code, tag, position) -> { + if (code == JsonLexer.EVT_VALUE) { + captured.clear(); + captured.put(tag); + } + }; + try (JsonLexer lexer = new JsonLexer(4, 1024)) { + // split immediately after the backslash, so the escape's '\' is in the first chunk and the + // 'n' it escapes is in the second + int split = json.indexOf('\\') + 1; + lexer.parse(address, address + split, parser); + lexer.parse(address + split, address + len, parser); + lexer.parseLast(); + TestUtils.assertEquals("ab\ncd", captured); + } finally { + Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + @Test + public void testUnicodeEscapeDecodedAcrossSplitParseCalls() throws Exception { + assertMemoryLeak(() -> { + // a backslash-u-XXXX escape whose four hex digits straddle two parse() calls (a real HTTP-fragment + // boundary) must still decode to one character: the lexer stashes the partial value and resolves the + // escape only once the whole value is assembled, so parseHex4 never sees a truncated escape + String bs = String.valueOf((char) 92); // a single backslash, built without a literal escape + String json = "{\"v\":\"x" + bs + "u0041y\"}"; // value x then the escape for A then y -> xAy + int len = json.length(); + long address = TestUtils.toMemory(json); + StringSink captured = new StringSink(); + JsonParser parser = (code, tag, position) -> { + if (code == JsonLexer.EVT_VALUE) { + captured.clear(); + captured.put(tag); + } + }; + try (JsonLexer lexer = new JsonLexer(4, 1024)) { + // split inside the four hex digits: backslash-u-0-0 lands in the first chunk, 4-1 in the second + int split = json.indexOf(bs) + 4; + lexer.parse(address, address + split, parser); + lexer.parse(address + split, address + len, parser); + lexer.parseLast(); + TestUtils.assertEquals("xAy", captured); + } finally { + Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + @Test + public void 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); + StringSink captured = new StringSink(); + JsonParser parser = (code, tag, position) -> { + if (code == JsonLexer.EVT_VALUE) { + captured.clear(); + captured.put(tag); + } + }; + try (JsonLexer lexer = new JsonLexer(4, 1024)) { + lexer.parse(address, address + len, parser); + lexer.parseLast(); + TestUtils.assertEquals(expected, captured); + } finally { + Unsafe.free(address, len, MemoryTag.NATIVE_DEFAULT); + } + } + private void assertError(String expected, int expectedPosition, String input) { int len = input.length(); long address = TestUtils.toMemory(input); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java new file mode 100644 index 00000000..c8ea1c56 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderErrorResponseTest.java @@ -0,0 +1,312 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.line; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.test.cutlass.auth.MockOidcServer; +import org.junit.Assert; +import org.junit.Test; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Verifies that the error body a QuestDB HTTP endpoint returns on a failed flush is rendered safely + * into the {@link LineSenderException} message. A JSON error body has its string escapes resolved by the + * lexer, so a {@code message} or {@code errorId} field arrives fully decoded; an auth (401/403) body, a + * non-JSON body, and a body that fails to parse as JSON are echoed verbatim. In every case a hostile or + * proxied endpoint could otherwise smuggle real control characters, ANSI escapes or bidi overrides that + * forge a log line or rewrite a terminal when the exception text is printed. The sender must escape them, + * just as it does for column names in an error message. + *

+ * The dangerous bytes are built at runtime via {@code (char) 0x1b} (ESC) and {@code (char) 0x202e} (a + * right-to-left override), so this source file stays pure ASCII and carries none of the chars it guards. + */ +public class LineHttpSenderErrorResponseTest { + + // ESC: the lead byte of an ANSI escape sequence (terminal hijack) + private static final char ESC = 0x1b; + // U+202E RIGHT-TO-LEFT OVERRIDE: reorders displayed text (visual spoofing) + private static final char RLO = 0x202e; + + @Test(timeout = 30_000) + public void testProtocolDetectionErrorBodyControlAndBidiAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // when the caller does not pin a protocol version, build() probes the server for one; a + // non-success, non-404 probe response body is captured into the "Failed to detect server line + // protocol version" exception. A hostile or proxied endpoint must not splice control, ANSI or + // bidi chars into that message any more than into a flush error + String errorBody = "probe denied " + ESC + "[2J forged\n" + RLO + "moc.live"; + try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) { + try { + // no protocolVersion(...) -> build() runs the detection probe; retryTimeoutMillis(0) makes + // it give up after the first failed probe instead of retrying to a deadline + Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .retryTimeoutMillis(0) + .build() + .close(); + Assert.fail("expected protocol detection to fail and surface the server body"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("Failed to detect server line protocol version")); + Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("probe denied")); + Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b")); + Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e")); + Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0); + Assert.assertFalse("a raw newline must not leak: " + msg, msg.indexOf('\n') >= 0); + Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0); + } + } + }); + } + + @Test(timeout = 30_000) + public void testServerAuthErrorBodyControlAndBidiAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // a 401/403 body is echoed into the exception verbatim (read as raw bytes, not through the JSON + // parser), so a hostile or proxied endpoint could splice raw control, ANSI or bidi chars straight + // into the LineSenderException; the sender must escape them just like the JSON-field path + String errorBody = "denied " + ESC + "[2J forged\n" + RLO + "moc.live"; + try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(401, errorBody))) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the server's auth error to surface as a LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("authentication error")); + Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("denied")); + Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b")); + Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e")); + Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0); + Assert.assertFalse("a raw newline must not leak: " + msg, msg.indexOf('\n') >= 0); + Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0); + } + } + } + }); + } + + @Test(timeout = 30_000) + public void testServerErrorStatusLineControlCharsAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // the HTTP status-line token is echoed into the exception as "[http-status=...]". The header parser + // copies it verbatim between the two spaces, so a hostile or proxied endpoint can smuggle control or + // ANSI bytes there; a non-3-char token bypasses the numeric status checks and reaches the generic + // error path, so the status render must escape them too, not just the body. A bidi override is a + // multi-byte char the raw-response writer's US-ASCII encoding would drop, so this case uses an ESC; + // the bidi cases above cover the body + String body = "upstream error"; + // a malformed status code "400[m" (6 chars, not 3) carries an ESC between the two spaces; + // text/plain keeps it off the JSON parser, so it reaches the generic path that renders the status + String rawResponse = "HTTP/1.1 400" + ESC + "[m FORGED\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n\r\n" + + Integer.toHexString(body.length()) + "\r\n" + body + "\r\n" + + "0\r\n\r\n"; + try (MockOidcServer server = new MockOidcServer((method, path, b) -> MockOidcServer.raw(rawResponse))) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the server's error to surface as a LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("Could not flush buffer")); + // the ESC smuggled into the status token arrives escaped, never as a raw byte that + // could drive an ANSI terminal sequence + Assert.assertTrue("the status-line ESC must be escaped: " + msg, msg.contains("\\u001b")); + Assert.assertFalse("a raw ESC must not leak from the status line: " + msg, msg.indexOf(0x1b) >= 0); + } + } + } + }); + } + + @Test(timeout = 30_000) + public void testServerJsonErrorBidiAndZeroWidthAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // beyond C0 controls, a hostile or proxied endpoint can smuggle bidi overrides and zero-width + // characters (as JSON \\uXXXX escapes the lexer decodes) that reorder or hide text in a terminal. + // The sender must escape these too, matching the OIDC display sanitizer, so the rendered message + // cannot be visually spoofed + String errorBody = "{" + + "\"code\":\"invalid\"," + + "\"message\":\"safe\\u202ehidden\\u200bend\"," + + "\"line\":1," + + "\"errorId\":\"E1\"" + + "}"; + try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the server's JSON error to surface as a LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + // the visible text survives, but the bidi override (U+202E) and the zero-width space + // (U+200B) arrive escaped, never as raw code points that could reorder or hide text + Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("safe")); + Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("hidden")); + Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e")); + Assert.assertTrue("the zero-width space must be escaped: " + msg, msg.contains("\\u200b")); + Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0); + Assert.assertFalse("a raw zero-width space must not leak: " + msg, msg.indexOf(0x200b) >= 0); + } + } + } + }); + } + + @Test(timeout = 30_000) + public void testServerJsonErrorControlCharsAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // the server's error body carries control characters as JSON escapes: an ESC and a newline in + // the message, and an ESC in the errorId. The lexer decodes them to real bytes, so the sender's + // error rendering is what must neutralize them + String errorBody = "{" + + "\"code\":\"invalid\"," + + "\"message\":\"bad\\u001b[m\\nthing\"," + + "\"line\":42," + + "\"errorId\":\"E\\u001bID\"" + + "}"; + // a chunked 400 with Content-Type application/json drives the flush failure through the sender's + // JSON error parser (a 4xx response is asserted to be chunked before parsing) + try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + // an explicit protocol version keeps build() from probing the server, so the only + // request is the flush below + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the server's JSON error to surface as a LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("Could not flush buffer")); + // the decoded message text survives... + Assert.assertTrue("decoded message text must be preserved: " + msg, msg.contains("bad")); + Assert.assertTrue("decoded message text must be preserved: " + msg, msg.contains("thing")); + Assert.assertTrue("errorId must be present with its ESC escaped: " + msg, msg.contains("id: E\\u001bID")); + // ...but no raw control byte reaches the message: no ESC (ANSI injection) and no + // newline (log-line forging); both arrive escaped instead + Assert.assertTrue("the decoded ESC must be escaped, not raw: " + msg, msg.contains("\\u001b")); + Assert.assertFalse("a raw ESC must not leak into the message: " + msg, msg.indexOf(0x1b) >= 0); + Assert.assertFalse("a raw newline must not leak into the message: " + msg, msg.indexOf('\n') >= 0); + } + } + } + }); + } + + @Test(timeout = 30_000) + public void testServerMalformedJsonErrorBodyControlAndBidiAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // a body sent as application/json but not parseable as a QuestDB error object (a proxy/WAF page, + // or an unexpected first key) makes the JSON parser throw; the fallback renders the raw body, which + // must still be escaped. The unexpected first key "forged" forces the parse failure; the ESC and + // bidi override ride in the value and must surface escaped, not raw + String errorBody = "{\"forged\":\"x " + ESC + "[2J y " + RLO + " z\"}"; + try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.chunkedJson(400, errorBody))) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the malformed server response to surface as a LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("Could not flush buffer")); + // the raw body is shown (so the user can diagnose the unexpected response)... + Assert.assertTrue("the raw body must be preserved: " + msg, msg.contains("forged")); + // ...but the smuggled control and bidi chars arrive escaped, never raw + Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b")); + Assert.assertTrue("the bidi override must be escaped: " + msg, msg.contains("\\u202e")); + Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0); + Assert.assertFalse("a raw bidi override must not leak: " + msg, msg.indexOf(0x202e) >= 0); + } + } + } + }); + } + + @Test(timeout = 30_000) + public void testServerNonJsonErrorBodyControlCharsAreEscaped() throws Exception { + assertMemoryLeak(() -> { + // a proxy or WAF can return a non-JSON error body (here text/plain) with raw ANSI/control bytes; + // it reaches the generic error path, which must escape them before they hit a log or terminal. + // The body is all ASCII (a real ESC and a newline) so it survives the raw response writer's + // US-ASCII encoding; bidi is covered by the auth/malformed cases above + String body = "upstream down " + ESC + "[31m forged\nsecond line"; + // hand-craft a chunked text/plain response: the generic path only reads the body when chunked, and + // a non-application/json content type keeps it off the JSON parser + String rawResponse = "HTTP/1.1 400 Bad Request\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n\r\n" + + Integer.toHexString(body.length()) + "\r\n" + body + "\r\n" + + "0\r\n\r\n"; + try (MockOidcServer server = new MockOidcServer((method, path, b) -> MockOidcServer.raw(rawResponse))) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:" + server.port()) + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .build()) { + sender.table("t").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the server's non-JSON error to surface as a LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue(msg, msg.contains("Could not flush buffer")); + Assert.assertTrue("visible text must be preserved: " + msg, msg.contains("upstream down")); + Assert.assertTrue("the ESC must be escaped: " + msg, msg.contains("\\u001b")); + Assert.assertFalse("a raw ESC must not leak: " + msg, msg.indexOf(0x1b) >= 0); + Assert.assertFalse("a raw newline must not leak: " + msg, msg.indexOf('\n') >= 0); + } + } + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java new file mode 100644 index 00000000..9a07c749 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java @@ -0,0 +1,154 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Verifies that a {@link Sender} built with {@link Sender.LineSenderBuilder#httpTokenProvider} + * does not query the provider on the build path: the first token pull is deferred to the first + * row. That lets a provider which signs in lazily - the documented + * {@code .httpTokenProvider(auth::getToken)} - be wired before the interactive sign-in + * has completed. + *

+ * An explicit {@code protocol_version} keeps {@link Sender.LineSenderBuilder#build()} from probing + * the server, and auto-flush is disabled, so rows can be buffered against a port nobody listens on + * without ever opening a connection. Each test runs under {@code assertMemoryLeak} so the sender's + * native buffers are proven freed on close. + */ +public class LineHttpSenderTokenProviderTest { + + @Test + public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception { + assertMemoryLeak(() -> { + // a provider that throws until the caller has signed in, mirroring OidcDeviceAuth::getToken + AtomicBoolean signedIn = new AtomicBoolean(false); + HttpTokenProvider provider = () -> { + if (!signedIn.get()) { + throw new LineSenderException("no token has been obtained yet"); + } + return "TOKEN"; + }; + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:1") + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .httpTokenProvider(provider) + .build()) { + // build() must succeed even though the provider cannot supply a token yet, so the natural + // "construct the sender, sign in, then send" ordering is possible + try { + sender.table("t").longColumn("v", 1L).atNow(); + Assert.fail("expected the not-yet-signed-in provider to fail the first row"); + } catch (LineSenderException e) { + // the deferred pull surfaces the provider's error at first use, not at build time + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no token has been obtained yet")); + } + // after signing in, the still-pending stamp is retried and the row is accepted + signedIn.set(true); + sender.table("t").longColumn("v", 1L).atNow(); + Assert.assertTrue("row must be buffered after signing in", sender.bufferView().size() > 0); + } + }); + } + + @Test + public void testControlOrNonAsciiProviderTokenIsRejected() throws Exception { + assertMemoryLeak(() -> { + // a token carrying a control or non-ASCII char is forbidden by the HttpTokenProvider contract: a + // CR/LF would inject into the request line and a non-ASCII byte is silently truncated by the ASCII + // header writer, so the sender must reject it at first use rather than splice a corrupt or injected + // "Authorization: Bearer " header onto the wire. Strings are built with explicit char values to keep + // this source pure ASCII. + assertProviderTokenRejected(() -> "abc" + (char) 0x0d + (char) 0x0a + "def", "control or non-ASCII character"); // CR/LF + assertProviderTokenRejected(() -> "tok" + (char) 0x00 + "en", "control or non-ASCII character"); // NUL + assertProviderTokenRejected(() -> (char) 0x1b + "[31mred", "control or non-ASCII character"); // ANSI escape + assertProviderTokenRejected(() -> "tok" + (char) 0xe9 + "n", "control or non-ASCII character"); // non-ASCII + }); + } + + @Test + public void testNullOrEmptyProviderTokenIsRejected() throws Exception { + assertMemoryLeak(() -> { + // the HttpTokenProvider contract forbids a null or empty token; the sender must reject it with a + // clear LineSenderException at first use, rather than silently send a malformed "Authorization: + // Bearer " header that the server only answers with a 401 far from the cause + assertProviderTokenRejected(() -> null, "null or empty token"); + assertProviderTokenRejected(() -> "", "null or empty token"); + assertProviderTokenRejected(() -> " ", "null or empty token"); + }); + } + + @Test + public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() throws Exception { + assertMemoryLeak(() -> { + AtomicInteger calls = new AtomicInteger(); + HttpTokenProvider provider = () -> { + calls.incrementAndGet(); + return "TOKEN"; + }; + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:1") + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .httpTokenProvider(provider) + .build()) { + // build() must not query the provider: a lazily-signing-in provider would not have a token yet + Assert.assertEquals("provider must not be queried at build time", 0, calls.get()); + // the first row pulls the deferred token so the first send will carry it + sender.table("t").longColumn("v", 1L).atNow(); + Assert.assertEquals("provider must be queried when the first row starts", 1, calls.get()); + // a second row in the same un-flushed batch reuses the same request, so it does not re-pull + sender.table("t").longColumn("v", 2L).atNow(); + Assert.assertEquals("provider must not be re-queried within the same batch", 1, calls.get()); + } + }); + } + + private static void assertProviderTokenRejected(HttpTokenProvider provider, String expectedMessage) { + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("127.0.0.1:1") + .protocolVersion(Sender.PROTOCOL_VERSION_V1) + .disableAutoFlush() + .httpTokenProvider(provider) + .build()) { + try { + sender.table("t").longColumn("v", 1L).atNow(); + Assert.fail("expected an invalid provider token to be rejected"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java index 4d05487e..c6ddcb05 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderExceptionTest.java @@ -53,6 +53,37 @@ public void testMessage_PutAsPrintableWithNonPrintableInput() { } + @Test + public void testMessage_putAsPrintableEscapesBidiOverride() { + // U+202E RIGHT-TO-LEFT OVERRIDE is a BMP format char - regression guard for the existing behavior + LineSenderException e = new LineSenderException("char: ").putAsPrintable("a\u202Eb"); + assertEquals("char: a\\u202eb", e.getMessage()); + } + + @Test + public void testMessage_putAsPrintableEscapesLoneSurrogate() { + // a lone high surrogate has no displayable meaning and must be escaped, not passed through raw + LineSenderException e = new LineSenderException("char: ").putAsPrintable("a\uD800b"); + assertEquals("char: a\\ud800b", e.getMessage()); + } + + @Test + public void testMessage_putAsPrintableEscapesSupplementaryFormatChar() { + // U+E0001 LANGUAGE TAG is a supplementary-plane format char: it arrives as a surrogate pair and must + // be escaped (as both halves), not passed through raw, or it could hide or forge text in a log + String tagChar = new String(Character.toChars(0xE0001)); + LineSenderException e = new LineSenderException("char: ").putAsPrintable("a" + tagChar + "b"); + assertEquals("char: a\\udb40\\udc01b", e.getMessage()); + } + + @Test + public void testMessage_putAsPrintableKeepsEmoji() { + // U+1F600 GRINNING FACE is a normal supplementary char (not control or format) - emitted verbatim + String emoji = new String(Character.toChars(0x1F600)); + LineSenderException e = new LineSenderException("char: ").putAsPrintable("a" + emoji + "b"); + assertEquals("char: a" + emoji + "b", e.getMessage()); + } + @Test public void testMessage_withErrNo() { LineSenderException e = new LineSenderException("message").errno(10); 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)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java index d4ad155e..1d3c92be 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java @@ -51,6 +51,8 @@ public void testAllSettersRejectAfterConnect() throws Exception { assertRejects(c -> c.withBasicAuth("u", "p"), "withBasicAuth"); // withBearerToken assertRejects(c -> c.withBearerToken("tok"), "withBearerToken"); + // withBearerTokenProvider + assertRejects(c -> c.withBearerTokenProvider(() -> "tok"), "withBearerTokenProvider"); // withBufferPoolSize assertRejects(c -> c.withBufferPoolSize(2), "withBufferPoolSize"); // withClientId diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java new file mode 100644 index 00000000..be3ea8bf --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java @@ -0,0 +1,238 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.http.client.HttpClientException; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import org.junit.Assert; +import org.junit.Test; + +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Unit coverage for {@link QwpQueryClient#withBearerTokenProvider}: header + * synthesis, re-query at each resolve (a fresh token per WebSocket upgrade), + * token validation, null rejection, and mutual exclusion with the fixed-token + * and basic-auth setters - exercised both through + * {@link QwpQueryClient#getAuthorizationHeaderForTest()} (which resolves the + * header the same way a real upgrade does) and, for the real connect path, + * against a loopback mock that captures the upgrade's {@code Authorization} + * header and confirms a throwing provider fails the connection attempt. The + * post-connect guard for the setter lives in + * {@link QwpQueryClientPostConnectGuardTest}. + */ +public class QwpQueryClientTokenProviderTest { + + @Test + public void testProviderConflictsWithBasicAuth() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerTokenProvider(() -> "tok")) { + try { + c.withBasicAuth("u", "p"); + Assert.fail("withBasicAuth after withBearerTokenProvider must throw"); + } catch (IllegalStateException expected) { + // mutually exclusive + } + } + } + + @Test + public void testProviderConflictsWithBearerToken() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerTokenProvider(() -> "tok")) { + try { + c.withBearerToken("other"); + Assert.fail("withBearerToken after withBearerTokenProvider must throw"); + } catch (IllegalStateException expected) { + // mutually exclusive + } + } + } + + @Test + public void testProviderNullOrBlankReturnRejected() { + // validateToken rejects a null, empty or blank token RETURNED by the provider before it reaches the + // "Bearer " header (distinct from testProviderNullRejected, which rejects a null provider at the setter) + String[] bad = {null, "", " "}; + for (String token : bad) { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(() -> token)) { + try { + c.getAuthorizationHeaderForTest(); + Assert.fail("a null/empty/blank provider token must be rejected, was: [" + token + ']'); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("null or empty")); + } + } + } + } + + @Test + public void testProviderNullRejected() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)) { + try { + c.withBearerTokenProvider(null); + Assert.fail("a null provider must be rejected"); + } catch (IllegalArgumentException expected) { + // expected + } + } + } + + @Test + public void testProviderQueriedAtEachResolve() { + int[] counter = {0}; + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(() -> "tok-" + (counter[0]++))) { + // each resolve re-queries the provider, so a reconnect presents a fresh token + Assert.assertEquals("Bearer tok-0", c.getAuthorizationHeaderForTest()); + Assert.assertEquals("Bearer tok-1", c.getAuthorizationHeaderForTest()); + } + } + + @Test + public void testProviderSynthesizesBearerHeader() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(() -> "abc123")) { + Assert.assertEquals("Bearer abc123", c.getAuthorizationHeaderForTest()); + } + } + + @Test(timeout = 15_000) + public void testProviderTokenSentOnRealUpgrade() throws Exception { + // drive the REAL connect path (connect() -> resolveAuthorizationHeader -> runUpgradeWithTimeout), + // not the test hook: the upgrade request must carry the freshly pulled "Bearer ". The mock + // answers 404 (not auth-failed, not terminal) so connect() fails fast after the header was sent. + List authHeaders = Collections.synchronizedList(new ArrayList<>()); + ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + int port = listener.getLocalPort(); + byte[] respBytes = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + Thread serverThread = new Thread(() -> { + while (!listener.isClosed()) { + try { + Socket s = listener.accept(); + Thread handler = new Thread(() -> { + try (Socket sock = s) { + byte[] buf = new byte[8192]; + int n = sock.getInputStream().read(buf); + if (n < 0) { + return; + } + String request = new String(buf, 0, n, StandardCharsets.US_ASCII); + for (String line : request.split("\r\n")) { + if (line.regionMatches(true, 0, "Authorization:", 0, "Authorization:".length())) { + authHeaders.add(line.substring("Authorization:".length()).trim()); + } + } + OutputStream os = sock.getOutputStream(); + os.write(respBytes); + os.flush(); + } catch (Exception ignored) { + } + }, "qwp-token-upgrade-handler"); + handler.setDaemon(true); + handler.start(); + } catch (Exception ignored) { + return; + } + } + }, "qwp-token-upgrade-server"); + serverThread.setDaemon(true); + serverThread.start(); + + try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=127.0.0.1:" + port + ";failover=off;target=any;") + .withBearerTokenProvider(() -> "tok-0")) { + try { + client.connect(); + Assert.fail("expected connect to fail on a 404 upgrade"); + } catch (HttpClientException expected) { + // 404 is neither auth-failed nor terminal: the endpoint is exhausted and connect() fails - + // but the upgrade request already carried the Bearer header captured above + } + } finally { + listener.close(); + serverThread.join(500); + } + Assert.assertEquals("the provider's token must reach the real upgrade request", 1, authHeaders.size()); + Assert.assertEquals("Bearer tok-0", authHeaders.get(0)); + } + + @Test + public void testProviderTokenValidated() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(() -> "bad\ntoken")) { + try { + c.getAuthorizationHeaderForTest(); + Assert.fail("a token carrying a control character must be rejected"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("control or non-ASCII")); + } + } + } + + @Test + public void testSettingBearerTokenThenProviderConflicts() { + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000).withBearerToken("tok")) { + try { + c.withBearerTokenProvider(() -> "other"); + Assert.fail("withBearerTokenProvider after withBearerToken must throw"); + } catch (IllegalStateException expected) { + // mutually exclusive + } + } + } + + @Test(timeout = 10_000) + public void testThrowingProviderFailsConnect() throws Exception { + // a provider that throws must fail the connection attempt on the REAL connect path: + // resolveAuthorizationHeader runs once before the endpoint walk, so the throw propagates straight + // out of connect() as the provider's own error (not wrapped as "all endpoints unreachable") + try ( + ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + QwpQueryClient client = QwpQueryClient.fromConfig( + "ws::addr=127.0.0.1:" + listener.getLocalPort() + ";failover=off;target=any;" + ).withBearerTokenProvider(() -> { + throw new LineSenderException("provider down"); + }) + ) { + try { + client.connect(); + Assert.fail("a throwing provider must fail the connection attempt"); + } catch (RuntimeException expected) { + // the provider's own exception propagates directly (the header is resolved before the + // endpoint walk), not wrapped as a transport "all endpoints unreachable" error + Assert.assertTrue(expected.getClass().getName(), expected instanceof LineSenderException); + Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("provider down")); + Assert.assertFalse(expected.getMessage(), expected.getMessage().contains("unreachable")); + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java new file mode 100644 index 00000000..cb8b8ef4 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketTokenProviderTest.java @@ -0,0 +1,237 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Verifies that the WebSocket (QWP) transport accepts an + * {@link Sender.LineSenderBuilder#httpTokenProvider} and presents the provider's current token as the + * {@code Authorization: Bearer} header on every upgrade handshake - the initial connect and each + * reconnect - so a long-lived WebSocket sender follows token rotation the way the HTTP transport does. + * The provider is queried at handshake time, not per data frame, because an established WebSocket is + * not re-authenticated mid-stream. The fixed-token and username/password paths are covered too as a + * regression guard for the refactor that turned the captured header string into a per-handshake supplier. + *

+ * Each test runs under {@code assertMemoryLeak} so the sender's native buffers are proven freed on close. + */ +public class WebSocketTokenProviderTest { + + @Test + public void testProviderRequeriedOnEveryReconnect() throws Exception { + assertMemoryLeak(() -> { + // The handler ACKs the first frame then drops the connection, forcing the I/O loop to reconnect. + // The reconnect runs the same buildAndConnect path, so it must re-query the provider and present + // the next token on the new upgrade - proving refresh-at-handshake, not a token captured once. + AtomicInteger tokenSeq = new AtomicInteger(); + DropAfterFirstAckHandler handler = new DropAfterFirstAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:" + port) + .httpTokenProvider(() -> "TOKEN-" + tokenSeq.incrementAndGet()) + .build()) { + Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS)); + + // batch 1 lands, gets ACKed, then the server drops the socket -> reconnect + sender.table("foo").longColumn("v", 1L).atNow(); + sender.flush(); + + // the reconnect handshake must carry a freshly pulled token (blocks for the reconnect) + Assert.assertEquals("Bearer TOKEN-2", server.pollAuthorizationHeader(5, TimeUnit.SECONDS)); + + // batch 2 goes through on the new connection, end to end + sender.table("foo").longColumn("v", 2L).atNow(); + sender.flush(); + waitFor(() -> handler.totalBinaryReceived.get() >= 2, 5_000); + } + } + }); + } + + @Test + public void testProviderTokenSuppliedOnInitialUpgrade() throws Exception { + assertMemoryLeak(() -> { + AtomicInteger tokenSeq = new AtomicInteger(); + try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:" + port) + .httpTokenProvider(() -> "TOKEN-" + tokenSeq.incrementAndGet()) + .build()) { + // the upgrade handshake runs during build(); the provider was queried exactly once for it + Assert.assertEquals("Bearer TOKEN-1", server.pollAuthorizationHeader(5, TimeUnit.SECONDS)); + Assert.assertEquals(1, tokenSeq.get()); + + // sending data must NOT re-query the provider: the established socket carries no new auth + sender.table("foo").longColumn("v", 1L).atNow(); + sender.flush(); + Assert.assertEquals(1, tokenSeq.get()); + } + } + }); + } + + @Test + public void testStaticTokenStillSuppliedOverWebSocket() throws Exception { + assertMemoryLeak(() -> { + // regression guard for the supplier refactor: a fixed httpToken still reaches the upgrade header + try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:" + port) + .httpToken("static-token") + .build()) { + Assert.assertEquals("Bearer static-token", server.pollAuthorizationHeader(5, TimeUnit.SECONDS)); + sender.table("foo").longColumn("v", 1L).atNow(); + sender.flush(); + } + } + }); + } + + @Test + public void testUsernamePasswordStillSuppliedOverWebSocket() throws Exception { + assertMemoryLeak(() -> { + // regression guard for the supplier refactor: username/password still becomes the Basic header + try (TestWebSocketServer server = new TestWebSocketServer(new AckHandler())) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:" + port) + .httpUsernamePassword("user", "pass") + .build()) { + String expected = "Basic " + Base64.getEncoder().encodeToString( + "user:pass".getBytes(StandardCharsets.UTF_8)); + Assert.assertEquals(expected, server.pollAuthorizationHeader(5, TimeUnit.SECONDS)); + sender.table("foo").longColumn("v", 1L).atNow(); + sender.flush(); + } + } + }); + } + + // Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16 + private static byte[] buildAck(long seq) { + byte[] buf = new byte[1 + 8 + 2]; + ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x00); // STATUS_OK + bb.putLong(seq); + bb.putShort((short) 0); + return buf; + } + + private static void waitFor(BoolCondition cond, long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (cond.test()) return; + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Assert.fail("interrupted"); + } + } + Assert.fail("waitFor timed out after " + timeoutMillis + "ms"); + } + + @FunctionalInterface + private interface BoolCondition { + boolean test(); + } + + /** ACKs every binary frame so the sender doesn't hang. */ + private static class AckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * ACKs every binary frame; on the first connection's first frame it closes the socket right after + * the ACK, so the sender's I/O loop must reconnect to deliver the next batch. Later connections ACK + * normally. + */ + private static class DropAfterFirstAckHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicInteger connectionsAccepted = new AtomicInteger(); + final AtomicLong totalBinaryReceived = new AtomicLong(); + private final AtomicLong nextSeq = new AtomicLong(0); + private TestWebSocketServer.ClientHandler firstClient; + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (firstClient == null || firstClient != client) { + connectionsAccepted.incrementAndGet(); + if (firstClient == null) { + firstClient = client; + } + } + totalBinaryReceived.incrementAndGet(); + try { + client.sendBinary(buildAck(nextSeq.getAndIncrement())); + if (totalBinaryReceived.get() == 1) { + // brief sleep so the queued ACK flushes before we close the socket under it + Thread.sleep(50); + client.close(); + } + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java index 806d3750..48329d10 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java @@ -42,8 +42,10 @@ import java.security.MessageDigest; import java.util.Base64; import java.util.List; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -55,6 +57,9 @@ public class TestWebSocketServer implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(TestWebSocketServer.class); private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + // Authorization header value captured from each well-formed upgrade request ("" when absent), in + // arrival order. Tests poll this to assert the token a provider supplied at each (re)handshake. + private final BlockingQueue capturedAuthHeaders = new LinkedBlockingQueue<>(); private final List clients = new CopyOnWriteArrayList<>(); private final boolean emitDurableAckHeader; private final WebSocketServerHandler handler; @@ -190,6 +195,14 @@ public int liveConnectionCount() { return liveConnections.get(); } + /** + * Authorization header value seen on the next upgrade handshake ("" if the request carried none), + * in arrival order. Blocks up to the timeout for a handshake to arrive; returns null on timeout. + */ + public String pollAuthorizationHeader(long timeout, TimeUnit unit) throws InterruptedException { + return capturedAuthHeaders.poll(timeout, unit); + } + /** * Replaces the advertised role for subsequent handshakes (live update). */ @@ -459,16 +472,20 @@ private boolean performHandshake() throws IOException { } String key = null; + String authorization = ""; for (String line : request.toString().split("\r\n")) { - if (line.toLowerCase().startsWith("sec-websocket-key:")) { + String lower = line.toLowerCase(); + if (lower.startsWith("sec-websocket-key:")) { key = line.substring(18).trim(); - break; + } else if (lower.startsWith("authorization:")) { + authorization = line.substring("authorization:".length()).trim(); } } if (key == null) { return false; } + capturedAuthHeaders.add(authorization); // Arbitrary-status reject path: tests use setRejectWithStatus // to drive the failover loop's terminal-vs-transient diff --git a/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java new file mode 100644 index 00000000..e8ea79e0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/example/OIDCAuthExample.java @@ -0,0 +1,70 @@ +package io.questdb.client.test.example; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.OidcDeviceAuth; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +public class OIDCAuthExample { + public static void main(String[] args) { + + // Discover the client id, scope and endpoints from the QuestDB server's /settings: + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "http://localhost:9000", + new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true) + )) { + // one-time interactive sign-in; caches token + refresh token + auth.signIn(); + + // ingress - ILP over HTTP + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("localhost:9000") + .httpTokenProvider(auth::getToken) + .build()) { + sender.table("abcde") + .longColumn("c0", 25) + .atNow(); + } + + // ingress - QWP + try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:9000") + .httpTokenProvider(auth::getToken) + .build()) { + sender.table("abcde") + .longColumn("c0", 28) + .atNow(); + } + + // egress - QWP + CollectingHandler handler = new CollectingHandler(); + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000) + .withBearerTokenProvider(auth::getToken)) { + client.connect(); + client.execute("SELECT c0, ts FROM abcde", handler); + } + } + } + + static final class CollectingHandler implements QwpColumnBatchHandler { + public void onBatch(QwpColumnBatch batch) { + batch.forEachRow(row -> { + long c0 = row.getLongValue(0); + // QuestDB TIMESTAMP columns arrive as microseconds since the Unix epoch + Instant ts = Instant.EPOCH.plus(row.getLongValue(1), ChronoUnit.MICROS); + System.out.printf("%d %s%n", c0, ts); + }); + } + + public void onEnd(long totalRows) { + } + + public void onError(byte status, String message) { + System.err.println("query failed: status=" + status + " msg=" + message); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java b/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java new file mode 100644 index 00000000..c65b6d49 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/std/str/DisplaySafeTest.java @@ -0,0 +1,106 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.std.str; + +import io.questdb.client.std.str.DisplaySafe; +import org.junit.Assert; +import org.junit.Test; + +/** + * Direct coverage for {@link DisplaySafe}, the single source of truth for whether a code point may be shown + * verbatim in a terminal or a log line. Both {@code Utf16Sink.putAsPrintable} and the OIDC display sanitizer + * delegate to it, so a regression here would silently weaken every display-escaping path - yet the classifier + * was previously exercised only transitively, for the few code points the integration tests happen to use. + *

+ * The unsafe code points (controls, Unicode format chars, surrogates, bidi controls, the BOM) are written as + * hex literals so this source stays pure ASCII and carries none of the chars it asserts on. + */ +public class DisplaySafeTest { + + @Test + public void testC0C1ControlsAndDelAreUnsafe() { + // C0 (incl. TAB/LF/CR/ESC), DEL, and the C1 block: every ISO control must be escaped + int[] unsafe = {0x00, 0x07, 0x08, 0x09, 0x0A, 0x0D, 0x1B, 0x1F, 0x7F, 0x80, 0x90, 0x9F}; + for (int cp : unsafe) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertFalse("control " + hex + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + Assert.assertTrue("control " + hex + " must be unsafe", DisplaySafe.isUnsafeForDisplay(cp)); + } + } + + @Test + public void testFormatBidiAndBomAreUnsafe() { + // Cf format chars and the explicit bidi/BOM set that reorder, hide, or mark text - including the + // supplementary-plane "tag" chars that arrive as a surrogate pair and must be judged whole + int[] unsafe = { + 0x00AD, // soft hyphen + 0x200B, // zero-width space + 0x200E, 0x200F, // LRM, RLM + 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // LRE, RLE, PDF, LRO, RLO + 0x2066, 0x2067, 0x2068, 0x2069, // LRI, RLI, FSI, PDI + 0xFEFF, // BOM / zero-width no-break space + 0xE0001, // language tag + 0xE0020, 0xE007F // tag space, cancel tag + }; + for (int cp : unsafe) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertTrue("format " + hex + " must be unsafe", DisplaySafe.isUnsafeForDisplay(cp)); + Assert.assertFalse("format " + hex + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + } + } + + @Test + public void testLoneSurrogatesAreUnsafe() { + // a lone surrogate half has no displayable meaning; the code-point classifier must reject it + int[] surrogates = {0xD800, 0xDBFF, 0xDC00, 0xDFFF}; + for (int cp : surrogates) { + Assert.assertFalse("surrogate 0x" + Integer.toHexString(cp) + " must be unsafe", DisplaySafe.isDisplaySafe(cp)); + } + } + + @Test + public void testPrintableAsciiIsSafe() { + // the fast-path range 0x20..0x7e is the overwhelmingly common case and must always pass + for (int cp = 0x20; cp <= 0x7e; cp++) { + String hex = "0x" + Integer.toHexString(cp); + Assert.assertTrue("printable ASCII " + hex + " must be safe", DisplaySafe.isDisplaySafe(cp)); + Assert.assertFalse("printable ASCII " + hex + " must be safe", DisplaySafe.isUnsafeForDisplay(cp)); + } + } + + @Test + public void testPrintableSupplementaryCharsAreSafe() { + // a normal supplementary char (emoji, CJK extension) is neither control, format, nor surrogate and + // stays safe, so the classifier does not over-escape legitimate non-BMP text + int[] safe = { + 0x1F600, // grinning face emoji + 0x1F4A9, // pile of poo + 0x20000 // CJK Extension B ideograph + }; + for (int cp : safe) { + Assert.assertTrue("supplementary 0x" + Integer.toHexString(cp) + " must be safe", DisplaySafe.isDisplaySafe(cp)); + } + } +} 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..a1cfefb4 --- /dev/null +++ b/examples/src/main/java/com/example/sender/OidcDeviceFlowExample.java @@ -0,0 +1,49 @@ +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 and, on a machine with a + * browser, opens the URL for you; otherwise open it on any device (your laptop or your + * phone) and enter the code. The token is then cached in memory and refreshed silently, + * so re-running this does not prompt again. + */ +public class OidcDeviceFlowExample { + public static void main(String[] args) { + // Discover client id, scope, endpoints and the groups-in-token mode from the server. + // Alternatively, configure the identity provider explicitly with OidcDeviceAuth.builder(). + // The default prompt prints the URL and code AND opens the URL in your browser when one is + // available (best-effort; skipped on a headless host). To print only, pass options: + // import io.questdb.client.cutlass.auth.DeviceCodePrompt; + // OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT)) + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) { + auth.signIn(); // sign in once (prompts on first use, then caches and refreshes silently) + + // 1. Ingest with the QuestDB client over ILP-over-HTTP, presenting the token as a Bearer. + // Pass a provider, not the fixed token, so a long-lived sender follows silent refreshes. + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("questdb.example.com:9000") + .enableTls() + .httpTokenProvider(auth::getToken) + .build()) { + sender.table("trades") + .symbol("symbol", "ETH-USD") + .doubleColumn("price", 2615.54) + .atNow(); + } + + // 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= + } + } +}