feat(core): add OIDC sign-in via device flow (RFC 8628) - #52
Conversation
isUnsafeForDisplay() inspected one UTF-16 code unit at a time, so a supplementary-plane (>= U+10000) format or control character - an invisible U+E00xx "tag" char, for instance - arrived as a surrogate pair whose halves are each neither a control nor category Cf and so passed the filter unstripped. Because the JSON lexer reassembles such 😀-style escapes, a hostile or man-in-the-middled identity provider could smuggle invisible/spoofing characters into a user_code, a verification_uri, or an error_description and on into the terminal prompt and exception messages. Judge a Unicode code point instead: isUnsafeForDisplay() takes an int, and both sanitizers (putSanitized for exception messages, sanitizeForDisplay for the prompt) walk the text by code point with Character.codePointAt/charCount, so Character.getType classifies a supplementary char as one character. A legitimate astral character (an emoji) is still preserved. Make the assertNoUnsafeDisplayChars test helper code-point-aware too - it shared the blind spot - and add a regression test that fails (the U+E0001 tag char survives) without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pollOnce() checked for a token before the HTTP status and the OAuth error field, so a response that carried a token alongside an error, or under a non-2xx status, was cached as a valid grant. tryRefresh() had the same flaw: it accepted the refreshed token on token presence alone. Both contradict RFC 6749 - 5.1 makes a grant a 2xx response carrying a token, and 5.2 says an error response must not be treated as a grant. Handle the OAuth error first in pollOnce(), so a token smuggled alongside an error never counts, and accept a token only when the status is 2xx; a token under a non-2xx status goes to the transport- error budget instead of being trusted. Guard tryRefresh() the same way: cache the refreshed token only from a clean 2xx response with no error, otherwise fall back to the interactive flow. The happy path and the existing pending/slow_down/access_denied/empty- body outcomes are unchanged. Add regression tests for a token alongside an error, a token under a non-2xx status, and a refresh that smuggles a token with an error - each fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
newRequest() passed the token from httpTokenProvider.getToken() straight to authToken(), which does not null- or empty-check it. A provider that returned null, "", or whitespace therefore produced a malformed "Authorization: Bearer " header that the server only answered with a 401 far from the cause - no client-side error at all. The HttpTokenProvider contract forbids such a return but nothing enforced it, and httpToken() already rejects a blank token, so the provider path was the weaker spot. Validate the pulled token with Chars.isBlank (as httpToken does) and throw a clear LineSenderException instead. The check sits inside the deferred pull, so a rejected token leaves the stamp pending and the next row retries cleanly, just like a throwing provider does. OidcDeviceAuth never returns a blank token, so this guards custom providers. Add tests that a null, an empty, and a whitespace-only provider token is rejected at first use - each fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JsonLexer.getCharSequence rescanned every decoded value and name from the start to look for a backslash, even though the parse loop already detects one when it sets ignoreNext. Record that in a sawEscape flag (carried across parse() fragments) and resolve escapes only when it is set, so the common no-escape value returns the assembled sink without a second pass. OidcDeviceAuth.Endpoint.parse now rejects a host that contains control characters or whitespace - a smuggled CR/LF would otherwise flow into the outbound Host header. Add the tests these paths lacked: a cross-fragment escape; the lexer's lenient and exotic escape arms (surrogate pairs, \b/\f, unknown and malformed escapes, lone surrogates); the version-probe settings parser reading an escaped key through unescape; HTTP-token-provider rejection for UDP and WebSocket (not just TCP); and the control-character host cases above. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the issuer feature from py-questdb-client (PR #133) onto OidcDeviceAuth, so the device flow keeps working against servers that do not advertise their device-authorization endpoint, and so the device code and refresh token are only sent where the caller pins. The issuer plays three roles: - Discovery fallback: when /settings omits the device (and/or token) endpoint, fromQuestDB(url, issuer) reads it from the issuer's .well-known/openid-configuration document. The discovery origin comes only from the out-of-band issuer (or an explicit discoveryUrl), never from a /settings-supplied value, so a tampered /settings cannot redirect discovery. Without a pin, discovery is refused. - Plaintext-channel pin: a /settings response fetched over plaintext http to a non-loopback host (only reachable with allowInsecureTransport) cannot route credentials to its advertised endpoints without a pin. - Endpoint-origin pin: validateEndpointOrigins, enforced in Builder.build() on every construction path, requires the token and device endpoints to share one origin (RFC 8628 co-location) and, when an issuer is set, to belong to it. Config surface: Builder.issuer(...); new fromQuestDB overloads (url, issuer), (url, issuer, allowInsecure), and a 5-arg master taking issuer, discoveryUrl and a TLS config. Tradeoffs: - The co-location check makes the token and device endpoints share an origin. testPersistentTransportFailureDuringPollingAborts simulated an unreachable token endpoint with a dead second port; it now uses a new MockOidcServer.dropConnection() against a co-located path. - The origin pin compares scheme/host/port and ignores the path, so an identity provider that hosts its endpoints on a different origin than its issuer must be configured without an issuer. This matches the Python client. - allowInsecureTransport still relaxes the identity provider endpoints too (unchanged); the Python client always forces https/loopback for the IdP. Left as-is to avoid changing settled transport behavior. Adds 7 tests and updates the README OIDC section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Endpoint.parse now rejects control characters and whitespace anywhere in the url before splitting it. The host was already checked, but the path was not, so a tampered /settings or discovery document could carry a CR/LF in an endpoint path that the JSON lexer decodes and postForm writes verbatim onto the request line via .url(endpoint.path) - a header-injection / request-smuggling vector that the origin pin (which compares scheme/host/port only) does not catch. Validating the whole url up front also keeps it safe to echo in the parse error messages. fromQuestDB now derives the pin origin from a caller-supplied discoveryUrl when no issuer was resolved. Previously a discoveryUrl pin only took effect when discovery actually ran (an endpoint missing from /settings); when /settings advertised both endpoints the discovery branch was skipped and validateEndpointOrigins ran with a null issuer, so a compromised server could advertise both endpoints at an attacker origin and slip past the pin. The discoveryUrl pin now behaves like the issuer pin on every construction path. Adds regression tests for both: a CR/LF-injected advertised endpoint, path and query cases in Endpoint.parse, and discoveryUrl-pin accept and reject against on- and off-origin endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Endpoint.parse already rejected control characters and whitespace in the url, which kept it safe to echo into the exception messages once it passed validation. That scan did not catch bidi, zero-width or other format characters (U+202E, U+200B, U+FEFF, the Cf category, and the supplementary-plane tag characters), so a tampered /settings or discovery endpoint url could still smuggle one into an OidcAuthException message and reorder, hide or forge the log line it lands in. The url scan now runs per code point and also rejects anything isUnsafeForDisplay flags, so an OIDC url may carry no control, whitespace or display-unsafe character. Every raw url echo in Endpoint.parse, requireSecureTransport and fromQuestDB is therefore safe on screen as well as on the wire, and the rejection message sanitizes the url it reports. Adds a regression test covering a right-to-left override, a zero-width space, the BOM and a supplementary-plane tag character. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isUnsafeForDisplay now treats an unpaired UTF-16 surrogate as unsafe, so a lone surrogate half - which JsonLexer emits verbatim for a single backslash-u-XXXX escape and which codePointAt surfaces as a SURROGATE code point - is stripped from a user_code, verification_uri or error string before it reaches a terminal or a log line. A valid high+low pair is still reassembled by codePointAt and judged on its real category, so a legitimate emoji survives. The method comment is corrected too: codePointAt in the callers reassembles pairs, not the lexer. close() and the class Javadoc no longer claim an in-flight sign-in is cancelled "promptly". The cancel flag is observed between polls (within about 100ms) but a poll request already in flight is not interrupted, so close() can take up to one HTTP request timeout to return - still far short of the device-code lifetime. The docs now say so. Adds tests: lone high and low surrogates are stripped from the device challenge while an emoji survives; and the private isLoopbackHost classifier (which gates the plaintext-channel MITM pin) is pinned for localhost and the 127.0.0.0/8 block, and against non-loopback and spoofing hosts such as 127.evil.com, localhost.evil.com, 127.1 and 127.0.0.256. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The poll loop now clamps the slow_down-inflated interval to the same MAX_POLL_INTERVAL_SECONDS cap the initial interval already respects, so repeated slow_down responses from the identity provider cannot grow the wait without bound. The device-authorization, token and well-known parsers now reset their current field to FIELD_NONE after each value, matching SettingsDiscoveryParser. The parsers are not currently confusable - in well-formed JSON a name event always sets the field before the next value, array elements arrive as EVT_ARRAY_VALUE, and nested values are filtered by the depth check - so this is a defensive consistency fix that removes a latent field-confusion foot-gun rather than a behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JsonLexer.unescape no longer re-scans the value from the start to re-find the backslash the lexer already flagged via hasEscape; it walks the value once, copying plain characters and resolving escapes in place. That drops the now-dead "no escapes" early return and the separate prefix copy, so an escaped value is traversed about twice (decode then unescape) instead of three times. parseHex4 looks the hex digit up in the shared Numbers.hexNumbers table instead of Character.digit, keeping the same -1-on-non-hex contract. All of this is on the cold error/discovery/auth parse path, never on ingestion. Reorders pollForToken ahead of pollOnce so the private methods stay in alphabetical order; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 4 MiB response-body cap (MAX_RESPONSE_BODY_BYTES) that bounds the OIDC device flow against a hostile or MITM'd server streaming an endless body had no test coverage on the parseBody path. Add an oversizedJson() mode to MockOidcServer that streams a chunked, mostly-whitespace body past the cap, and a test that drives discovery against it and asserts the bounded read aborts with the size-limit error - which also confirms the token-bearing body never reaches the message. The body is whitespace so the lexer keeps consuming until the byte cap trips, instead of hitting its per-value length limit first. Verified both ways: the test passes with the 4 MiB cap and fails when the cap is disabled, where the full body is read and parsing fails with "Unterminated object" instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three small fixes to the OIDC device authorization flow, all in OidcDeviceAuth: - runDeviceFlow now rejects a non-2xx device authorization response. Previously it trusted any body that carried device_code/user_code/ verification_uri and no OAuth error, so a non-2xx response would prompt the user and start polling. It now applies the same 2xx gate pollOnce and tryRefresh already use before trusting a body. - pollForToken checks the device-code deadline at the top of the loop and never sleeps past it, so an expiry that elapses during a sleep times out promptly instead of after one more wasted poll and up to a full extra poll interval. - tryRefresh drops an unreachable branch that rethrew on an OAuth error. postForm only throws on a parse failure here, and a real OAuth error arrives in tokenParser.error (handled by the hasRequiredToken check), so the branch was dead. No behaviour change. Add testNonSuccessDeviceAuthorizationResponseRejected covering the new 2xx gate; it fails without the check (the 403 is accepted, the user is prompted, and polling fails later instead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A discoveryUrl pins the identity provider, yet fromQuestDB adopted the issuer the discovery document declared about itself and validated the token and device endpoints against that, never against the pinned discoveryUrl origin. A document served at the pinned url could therefore name an attacker issuer, co-locate both endpoints under it, and route the device code and the long-lived refresh token there while the co-location and issuer checks passed trivially - so the discoveryUrl pin did not in fact pin the provider, contradicting its documented guarantee. Reject a document whose own issuer sits on a different origin than the pinned discoveryUrl (RFC 8414 section 3.3), and derive the endpoint pin from the discoveryUrl origin rather than the document's self-declared issuer. An identity provider that serves its discovery document on a different origin than its endpoints must instead be configured with explicit endpoints via OidcDeviceAuth.builder(). The issuer-pinned path is unchanged: it already binds the endpoints to the caller-supplied issuer. testFromQuestDbDiscoveryUrlPinRejectsForeign IssuerInDocument covers the new rejection and fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readResponse copied the response status code into a sink that later appears in OidcAuthException messages. A well-formed status code is bare digits, but the HTTP header parser keeps the status-line token verbatim apart from SP/CR/LF, so a hostile or MITM'd identity provider could splice ESC or other control bytes into it - smuggling ANSI sequences into a log or terminal, or fabricating a leading digit that passes the 2xx success gate. Validate the status code as it is captured: on any non-digit byte, drain the body so the keep-alive connection stays usable, then reject the response with a message that echoes none of its bytes. A clean status is copied digit by digit, so every later [httpStatus=...] echo is bare digits. testNonNumericStatusCodeRejected drives a status code with a spliced ANSI reset and asserts the rejection; it fails without the fix. The new MockOidcServer.raw() helper writes a verbatim response so a test can craft a malformed status line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JsonLexer now resolves JSON string escapes, so the message and errorId fields a QuestDB endpoint returns in a JSON error body arrive at the sender fully decoded. The JSON error parser put them into the LineSenderException verbatim, so a hostile or proxied endpoint could inject real control characters or ANSI escapes that forge a log line or rewrite a terminal when the exception text is printed. Render the server-supplied message, id, code and line through putAsPrintable - the same escaping the column-name errors in this class already use - so a decoded control byte arrives escaped. LineHttpSenderErrorResponseTest flushes against a server returning a chunked JSON error whose message and errorId carry an ESC and a newline, and asserts they reach the exception escaped; it fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plaintext-channel pin refuses /settings-supplied OIDC endpoints fetched over a non-loopback http channel unless the identity provider is pinned out of band, so a tampered response cannot route the device code and refresh token to an attacker. Only its loopback exemption was exercised end to end, because the test mock binds to 127.0.0.1; the firing branch had no integration coverage. Reach the loopback mock through "127.1": the OS resolver expands the short form to 127.0.0.1 so the mock answers, but the loopback classifier deliberately rejects the short form, so the server host is non-loopback and the pin fires. Assert that a plaintext /settings advertising both endpoints without a pin is refused, and that pinning the issuer over the same channel is accepted - proving the pin, not an unrelated rejection, is the gate. The test fails if the firing check is removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Skip testPlaintextSettingsWithAdvertisedEndpointsRequiresPin on Windows: it reaches the loopback mock through the "127.1" short-form address, which Linux/macOS getaddrinfo expands to 127.0.0.1 but Windows getaddrinfo rejects, so discovery cannot connect there. No host string is both reachable at the loopback mock and classified non-loopback on Windows, so the end-to-end firing path cannot run there; the classifier stays covered cross-platform by testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing. Wrap every OidcDeviceAuth construction in try-with-resources so the native JSON lexer and HTTP clients are always released, including the rejection paths where build()/fromQuestDB() throws. Also replace manual StringBuilder fills with String.repeat, switch index loops to enhanced-for, and collapse the split-value test helper to a single lexer cache-limit parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A token whose JSON value carries an escaped CR/LF now decodes to real control bytes (the lexer resolves string escapes), and getToken() serves it verbatim as an "Authorization: Bearer <token>" header value and as the PG-wire _sso password. A control character would break out of the header and inject into the request line sent to the trusted QuestDB server; a non-ASCII character is silently truncated by the ASCII header writer. storeTokens now validates the access and id tokens and rejects any character outside printable ASCII (0x20-0x7E) before caching them, so a tampered or corrupt credential from a hostile or man-in-the-middled identity provider never reaches the wire. The refresh token is left unchecked: it is only ever sent URL-encoded. The token bytes are never embedded in the error message. Add testTokenWithControlCharsRejected, which fails without the guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AbstractChunkedResponse.recv re-armed the full timeout on every internal read while scanning an incomplete chunk-size line, so a server that dribbles that line one byte per timeout window - or fills the buffer with a CRLF-less chunk size - kept a single recv() running without bound. That defeats a caller's wall-clock deadline, e.g. OidcDeviceAuth.parseBody, whose comment claims a dribbling server cannot wedge the thread. recv(int) now bounds the whole call to the given timeout when it is positive: it tracks elapsed time, shrinks the per-read budget, and throws once the budget is exhausted. The first read still gets the full budget; a non-positive timeout keeps the legacy unbounded behaviour, so the existing test harness is unaffected. The Response.recv javadoc is updated to match. Add testRecvHonoursTotalTimeoutWhileChunkSizeDribbles, which hangs and trips its JUnit timeout without the bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
putAsPrintable rendered untrusted text - an ILP server's JSON error body, a column name - into a LineSenderException message escaping only C0 controls and DEL. Bidi overrides, zero-width joiners and the BOM passed through raw, so a hostile or proxied endpoint (whose JSON escapes the lexer now decodes to real code points) could reorder or hide the text a human reads in a terminal or a log line. It also truncated any escaped char above U+00FF to its low byte. putAsPrintable now escapes control characters and Unicode format characters, matching the OIDC display sanitizer's threat model, and emits the full four hex digits. Escaping rather than stripping keeps the original visible for diagnosis. For characters up to U+00FF the output is unchanged. This is the client's own Utf16Sink copy. Also close OIDC test-coverage gaps: - reject a malformed status code on the token-poll path, not only the device-authorization path - getTokenSilently fails fast while another thread holds the lock in a silent refresh, not only an interactive sign-in - a backslash-u escape split across parse() fragments still decodes - tighten the stalled-body timeout assertion to prove the configured 1s limit fired Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Condense the verbose comments and javadoc the device-flow PR added, across the new auth classes (OidcDeviceAuth, OidcAuthException, DeviceAuthorizationChallenge, DeviceCodePrompt, HttpTokenProvider) and the comments added to JsonLexer, Response, Utf16Sink, AbstractChunkedResponse, AbstractLineHttpSender and Sender. Drop filler, use active voice, and collapse wrapped lines while preserving every technical fact - the security rationale, RFC references, invariants, and ordering/locking notes. Comments only; no code changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DeviceCodePrompt.openBrowser() renders the device-code challenge and then opens the verification URL in the local default browser, best-effort: a new package-private BrowserLauncher allowlists http(s) schemes (rejecting javascript:/data:/file: from a hostile or MITM'd identity-provider response) and skips silently on a headless JVM or a runtime without the java.desktop module, so sign-in never breaks and the URL and code are always printed. Collapse OidcDeviceAuth.fromQuestDB's seven overloads into two: fromQuestDB(url) and fromQuestDB(url, DiscoveryOptions). DiscoveryOptions carries the issuer, discovery URL, TLS config, the insecure-transport opt-in, and the device-code prompt. Threading the prompt through the discovery path is the point: a custom prompt (such as openBrowser) previously worked only via the explicit builder(), which forgoes /settings discovery. Migrate the OidcDeviceAuth test call sites to the options form and add BrowserLauncherTest, which reaches the package-private allowlist by reflection (the client is an open module). Update the example and the README, including two now-removed overload references. Tests: OidcDeviceAuthTest (90) and BrowserLauncherTest (3) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The default device-code prompt is now openBrowser() in both the builder and DiscoveryOptions, so an interactive sign-in prints the verification URL and code and also opens the URL in the local default browser when one is available. SYSTEM_OUT becomes the explicit print-only opt-out. A new questdb.client.oidc.open.browser system property (default true) gates the launch in BrowserLauncher, so a server, automation or CI host can suppress it process-wide. OidcDeviceAuthTest sets it false so no device-flow test launches a real browser, under maven or an IDE - the default prompt would otherwise pop a tab for every flow that reaches the prompt. Update the javadocs, README and example accordingly. Tests: OidcDeviceAuthTest (90) and BrowserLauncherTest (4) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SettingsDiscoveryParser now reads acl.oidc.audience from the trusted config object, and fromQuestDB threads it into the builder, so the audience is discovered from the server rather than only set through builder(). tryRefresh() now appends the audience form parameter - the device authorization request already sent it - so both the device grant and the refresh request carry it, matching the Python client. The device-code poll does not, also matching Python. Tests: testDiscoveryReadsAudience and testAudienceSentOnRefresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The identity provider endpoints (device authorization, token, and the .well-known discovery URL) now require https unless they are loopback, regardless of allowInsecureTransport. The flag relaxes only the QuestDB /settings link; it no longer downgrades the identity provider, so the device code and refresh token are never sent in cleartext. Loopback http endpoints are accepted without the flag, for local development. When the pinned issuer carries a path, an endpoint from /settings must now be under that path, not just on the issuer's origin. A path-based multi-tenant provider (Keycloak /realms/<realm>) shares one origin per tenant, so the origin check alone could not stop a tampered /settings from steering credentials to a different realm. The check decodes repeatedly (%252e -> ..), folds backslashes, scans matrix params, and rejects any . or .. segment. Endpoints from IdP discovery or configured explicitly are not scoped, since some providers place endpoints outside the issuer path. Both changes match the behaviour of the Python client (py #133). Tests: testIdpEndpointsRequireHttpsExceptLoopback and three testIssuerPathScoping* tests; OidcDeviceAuthTest (95) and BrowserLauncherTest (4) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The device-code lifetime clamp now matches the Python client. A missing or zero expires_in in the device-authorization response defaults to 600s (was 300s), and an absurd value is capped at 1800s (was 3600s) via a new MAX_DEVICE_CODE_TTL_SECONDS, so a hostile or buggy provider cannot make the client poll for an absurd duration. The token-cache clamp is unchanged (300s default, 3600s cap); it previously shared the cap constant with the device-code clamp, now split so the two are independent. Test: testDeviceCodeLifetimeClamped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-read the durable acknowledgement after a recoverable terminal is observed. This prevents a wire recycle from carrying an exhausted capability-gap or rotating-auth budget across progress published by the I/O thread.
Use interrupt-neutral timed joins in PoolHousekeeper.stop so a fresh cancellation cannot skip the worker interrupt. Extend the token-provider recovery coverage with a closer interrupted while it is inside the join.
Both sit in FileTokenStore, both were found in review, and neither needs an attacker to reach. Reclaim a lock name this store cannot have written. stealIfStale returned the moment readLockHolder threw, so a directory, a symlink or a mode-000 file occupying .store.lock was never reclaimed: CREATE_NEW reports EEXIST for all three exactly as it does for a peer's live lock, so acquireLock spent its whole budget and threw, and threw again on every later call. Nothing else reclaims that name - the untrusted sweep skips it for want of a hash prefix, and sweepTempFiles globs *.tmp - so load() and save() failed for as long as the shape stood. Persistence then died silently: OidcDeviceAuth degraded to "continuing without persistence" and every process start re-ran the interactive device flow, which is a hard failure for the headless getToken() consumer persistence exists for. It also contradicted this file's own claim, where discardUntrustedDirectoryContents deliberately leaves .lock names in place, that acquireLock already treats a hostile or stale one as stealable. The shapes split into two cases. A directory or a symlink is a squatter that no wait turns into a lock, since createLockFile only ever produces a regular file, so displaceLockSquatter removes it on sight - the way markUntrusted already displaces a squatted .untrusted name. It captures atomically before deciding, so a peer that creates a real lock in the gap gets it restored rather than deleted. An unreadable regular file is genuinely ambiguous: a run under a different uid killed while holding the lock leaves one, and it may equally be a live holder's. stealIfStale therefore carries "stamp unreadable" as a third state beside "stamp" and "empty", ages it on the full staleness window rather than the short empty-lock grace, and folds readability into the capture-verify so a name that could not be read before the capture but yields a stamp after it is restored, not stolen. Two supporting changes fall out. The mtime reads move to NOFOLLOW_LINKS, because the link-following default threw on a dangling symlink - a second route into the same wedge - and because it keeps the before and after mtimes describing one object across the rename. The after-capture stamp and mtime reads split into separate try blocks: folded together, an unreadable stamp left afterModified null and would have made every such capture unconfirmable. Honour the untrusted sentinel on a non-POSIX filesystem. restrictToOwner returned a bare true from its UnsupportedOperationException branch, so a directory a POSIX peer had marked untrusted and not finished sweeping - its sweep latches whenever one entry resists deletion - read as trusted on Windows. This client then adopted entries out of it and presented their tokens, and neither swept nor cleared the mark. The sentinel is deliberately permission-independent, which is exactly what lets it carry a verdict across a filesystem whose mode bits this client cannot read; design/oidc-token-persistence.md requires honouring it whatever the permission bits say, and canUseShortDirectoryLockLease already evaluates it on the same catch. FileTokenStoreTest gains four tests. Three fail against the previous code with the reported OidcAuthException and pass now; the fourth pins the safety property the ageing buys - a fresh unreadable lock may be a live holder's and must survive. The non-POSIX branch stays untested: reaching it needs a Windows agent or a synthetic FileSystemProvider, as the class javadoc already records. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7VQ956HEEVH9fci5SF4QL
Tandem review — client #52 / OSS #7331 / ENT #1090Reviewed as one logical change at client Submodule provenance. Verdict: approve on all three. No Critical findings. Three Moderate items and a Minor bundle remain open below; none blocks. The test gate passes (one admitted coverage gap, Moderate). Compile compatibility is settled: public signature sets diffed base-vs-head show zero removals, both new interface methods are Resolved since the first passTwo findings from the initial review are fixed in
Four regression tests were added. The three that pin the wedge fail against the previous code with the reported Open — ModerateM1.
|
[PR Coverage check]😍 pass : 2475 / 2758 (89.74%) file detail
|
Level 3 tandem reviewReviewed in tandem with questdb/questdb#7331 and questdb/questdb-enterprise#1090. ModerateProblem: Final post-ACK interrupt capture lacks failure-linked coverage. The final interrupt capture in QwpWebSocketSender.java handles an interrupt arriving after the loop observes the final ACK. The new successful-drain test injects through A scratch mutation removing only the final capture passed the complete Coverage mapTest gate passes with 1 admitted Moderate coverage gap for this PR. The focused OIDC/QWP suite passed 177 tests, and the full JDK 8/25, Linux, macOS, Windows, coverage, and leak-check matrices are green. Summary
Both tandem submodule contents were independently reviewed because their PRs were explicitly supplied. No other submodule contents were expanded. |
Adds interactive OIDC sign-in to the Java client using the OAuth 2.0 Device Authorization Grant (RFC 8628). A process with no local browser — a remote notebook kernel, a container, a headless job — can sign a human in against QuestDB Enterprise: 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, by default, also tries to open the URL in a local browser); once the user authorizes, the token is cached in memory and refreshed silently on later calls.
What's new
OidcDeviceAuth(io.questdb.client.cutlass.auth) — runs the flow and owns the token:OidcDeviceAuth.fromQuestDB(url)discovers the client id, scope, audience and IdP endpoints from the server's unauthenticated/settings;OidcDeviceAuth.fromQuestDB(url, DiscoveryOptions)adds an identity-provider pin (.issuer(...)), a TLS config, anallowInsecureTransportopt-in, and the prompt (see Discovery and trust below);OidcDeviceAuth.builder()configures the identity provider explicitly.signIn()signs in interactively on first use, then serves a cached token and refreshes it silently;getToken()never prompts and never waits behind an interactive sign-in (safe on a request/flush path);getAuthorizationHeaderValue()returns the fullBearer …value;clearCache()drops the cached token so the nextsignIn()re-signs-in;close()cancels an in-flight sign-in (observed between polls, so it can take up to one HTTP request timeout to return). Calls are serialized by aReentrantLock;getToken()usestryLockand fails fast rather than wait behind an interactive sign-in. Token state is in-memory only by default; pass aTokenStoreto persist it across restarts (see Token persistence below).Senderintegration — newHttpTokenProviderinterface andSender.builder(...).httpTokenProvider(auth::getToken). The sender pulls a freshly refreshed token on every request, so a long-lived sender keeps working as the token rotates — unlike a fixedhttpToken(...), which is captured once and eventually starts returning 401s. Mutually exclusive withhttpToken/httpUsernamePassword. Supported over HTTP and WebSocket transport (a WebSocket sender re-queries the provider on every (re)connect/upgrade); rejected for TCP and UDP. The two transports differ in mechanism but both keep the producer alive across a sustained token outage: over HTTP a failed pull leaves the request token-pending and is retried on the next row; over WebSocket the token must be obtainable whenbuild()runs (the initial handshake fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely, with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender, just as a persistent transport reconnect failure does not (store-and-forward Invariant B). The first pull is deferred off the build path to the first row, so the documentedconstruct → signIn() → sendordering works and a provider that throws leaves the request retriable instead of corrupting the sender.QWP egress query client —
QwpQueryClient.withBearerTokenProvider(HttpTokenProvider)accepts the same on-demand provider, soOidcDeviceAuth::getTokenplugs into the egress query path as well as ingress. The provider is queried at every WebSocket upgrade — the initialconnect()and each failover reconnect — so a long-lived query client follows token rotation; each returned token is validated before it reaches the header, and a provider that throws fails that connection attempt (matching the ingress sender). Mutually exclusive withwithBearerToken/withBasicAuth.DeviceCodePrompt/DeviceAuthorizationChallenge— how the verification URL and user code are shown. The default,DeviceCodePrompt.openBrowser(), prints the instructions toSystem.outand also tries to open the verification URL in the local default browser; the browser open is best-effort (skipped on a headless JVM, without thejava.desktopmodule, or for a non-http(s)URL, and disabled by-Dquestdb.client.oidc.open.browser=false) and never blocks or fails sign-in. UseDeviceCodePrompt.SYSTEM_OUTto print only, or supply your own to render a clickable link or a QR code, e.g. in a notebook.audience—builder().audience(...)/ discovered fromacl.oidc.audience. When set, theaudienceparameter is sent on the device-authorization and refresh requests, for providers that require it to stamp theaudclaim QuestDB expects.The token can be presented to QuestDB over any auth path the server already validates:
Authorization: Bearer <token>._ssowith the token as the password (requiresacl.oidc.pg.token.as.password.enabled=trueon the server).Discovery and trust
fromQuestDB(...)takes the IdP endpoints from the server's unauthenticated/settings, so by default it trusts that server to designate where the user signs in: a spoofed, compromised, or man-in-the-middled server could otherwise redirect the sign-in — and the long-lived refresh token — to an attacker-controlled identity provider. An optionalDiscoveryOptions.issuer(...)pin addresses this, and also covers servers that do not advertise a device-authorization endpoint. The pin separates two sources of endpoints and trusts them differently — an endpoint the untrusted/settingsadvertised is constrained to the issuer, while an endpoint read from the identity provider's own.well-knownis trusted wherever the provider hosts it:.well-knowndiscovery fallback. Current servers do not advertise the device-authorization endpoint. When it (and/or the token endpoint) is missing, a pinned issuer reads it from{issuer}/.well-known/openid-configuration. The discovery origin comes only from the caller-suppliedissuer, never from a/settings-supplied value, so a tampered/settingscannot choose where discovery — and the credential POSTs it resolves — are aimed. Without a pin, discovery is refused rather than guessed.validateEndpointOrigins, enforced on every construction path (discovery and the explicitbuilder()), requires the token and device-authorization endpoints to share one origin (RFC 8628 co-locates them on a single authorization server), so a tampered/settingsor discovery document cannot siphon one of the two credential POSTs off to a different origin./settings-advertised endpoints are pinned to the issuer. An endpoint the untrusted/settingsresponse supplied must sit on the pinned issuer's origin, and — when the issuer has a path — under that path (compared segment by segment, rejecting./.., percent-encoded traversal, and a percent-encoded path separator such as%2for%5c, at every decode level). The path check matters for a path-based provider that shares one origin per tenant (e.g. a Keycloak realm path/realms/<realm>), where the origin check alone cannot stop a tampered/settingsfrom steering credentials to a sibling tenant. The issuer is supplied out of band and cannot be forged..well-knownis neither origin-pinned nor path-scoped: that document is fetched from the pinned issuer origin and is authoritative for wherever the provider hosts its endpoints. This is deliberate — some providers (e.g. Google, Azure AD) serve their token and device endpoints from a different origin or path than the issuer, and discovery against them signs in normally. The co-location pin above still applies./settingsresponse fetched over plaintexthttpto a non-loopback host (only reachable withallowInsecureTransport) is MITM-able, so its advertised endpoints are not trusted to route credentials without an issuer pin.Without a pin, the behaviour against an
httpsserver that advertises its endpoints is unchanged: that server is trusted, as before.Security
httpsis required by default for both the QuestDB server and the IdP endpoints;httpis rejected unless the caller opts in withallowInsecureTransport(true). That opt-in relaxes only the QuestDB/settingslink — the IdP device-authorization and token endpoints always requirehttps(loopback excepted), so the device code and refresh token never cross the network in cleartext (matching the Python client).[httpStatus=…]echo, nor can a short all-digit status (2,5) be misread as a 2xx/5xx class — a malformed-length status falls through to the terminal reject path.verification_uri_complete, treated as absent) rather than shown as a blank line or handed to the browser launcher.0x20–0x7e) before it is cached, placed in theAuthorization: Bearerheader, or used as the PG-wire password — so a tampered or hostile identity provider cannot smuggle a CR/LF into the request the client then sends to the trusted QuestDB server. Only the served kind is checked; a stray character in the unused token kind, which never reaches the wire, no longer aborts an otherwise usable grant. (TheJsonLexerchange below decodes JSON escapes, which is what turns a\r/\nin a token into a real byte rather than two literal characters.)Endpoint.parserejects control characters, whitespace and display-unsafe code points anywhere in the url (so a tampered endpoint cannot inject a CR/LF into the request line or a bidi char into a log line), rejects bracketed IPv6 literals rather than mis-parsing them, rejects userinfo (user@host) — which the HTTP layer would otherwise try to connect to literally — terminates the authority at the first/,?or#so a query or fragment is never folded into the host, and range-checks the port to 1..65535.System.nanoTime) deadline that bounds the whole read — covering both a chunked response whose chunk-size line is dribbled a byte at a time and aContent-Lengthbody dribbled through stalled TLS records, either of which previously could keep a single read running well past the deadline. After such a bounded-read abort the half-read poll connection is dropped so the next poll reconnects on a clean socket, rather than the loop spinning on the stalled response's leftover bytes until the device code expires. The device-code lifetime, the poll interval, and the token TTL are all clamped (defaults applied for absent/zero values, hard caps for absurd ones). A429with no OAuth error is treated as a transient back-off (a429that also carries a terminal error such asaccess_deniedstill aborts on the error); a transient transport failure or5xxduring polling keeps polling until the device-code deadline rather than failing the sign-in (RFC 8628; matches the Python client), while a definitive OAuth error or a terminal4xxaborts immediately. The trade-off is that a persistently flaky network is no longer cut short by a separate error budget — it polls to the device-code deadline.Token persistence (opt-in)
By default token state is in-memory only, so a restarted process re-runs the interactive device flow. Passing a
TokenStorepersists it, so the restarted process resumes from the saved refresh token (one silent token-endpoint round-trip) instead of re-prompting —getToken()then even works as the first call, with no explicitsignIn().TokenStoreSPI (io.questdb.client.cutlass.auth) —load/save/clearkeyed by a non-secretTokenStoreKey(endpoints, client id, scope, audience, groups-in-token mode), plus an optionalinLockhook for cross-process coordination. Wire it in withbuilder().tokenStore(...)orDiscoveryOptions.tokenStore(...). Persistence is best-effort: a store failure logs a warning through SLF4J atWARNand the in-memory token is used regardless — and the library shipsslf4j-apiwith no binding, so that warning, like every other client warning, is discarded unless the application supplies one.FileTokenStore(the default) — one plaintext JSON file per OIDC configuration under${user.home}/.questdb/oidc-tokens/(override withquestdb.client.oidc.token.store.dir), the refresh token protected at rest by file permissions (0600file,0700directory on POSIX) rather than encryption — the same approachgcloud,awsandghtake. The file name is a SHA-256 of that configuration (endpoints, client id, scope, audience, groups-in-token mode), so it leaks neither endpoint nor client id, and different servers, providers or client configurations stay in separate files.FileTokenStore.atDefaultLocation()/FileTokenStore.at(dir).FileTokenStore.at(dir)on a per-user directory, or a per-userquestdb.client.oidc.token.store.dir— rather than a reliance on the name to separate them. The default location is per OS user already, so this only arises inside one OS user: a shared service account, or a process signing in on behalf of several people.O_CREAT|O_EXCLlock file (not an OS advisory lock, which JavaFileLockand Pythonflockcannot share); a process that cannot acquire the lock degrades to a lock-free refresh rather than stall.design/oidc-token-persistence.md): the file name, JSON schema, atomic-write and lock-file protocols are specified so the Python client (and others) can share one file.Supporting changes
JsonLexernow resolves JSON string escape sequences (\",\\,\/,\b \f \n \r \t,\uXXXX; lenient on malformed input), so string values arrive fully decoded. This also reaches the existing ILP error-response parser, which now sees decodedmessage/code/line/errorIdfields.Response.recv(int timeout)— adefaultmethod delegating torecv(), so an implementation of this exported interface written before the overload existed keeps compiling and linking, and keeps its previous behaviour. Both implementations here override it. It bounds the whole read to the timeout in total, not per socket read, so a server that dribbles the body — the chunk-size line of a chunked response, orContent-Lengthbytes behind stalled TLS records — cannot keep a single read running past the caller's deadline. A non-positive timeout keeps the legacy unbounded behaviour. Every ILP flush-path body read now passes an explicit timeout —actualTimeoutMillis, the base request timeout plus the throughput extension, not the rawrequest_timeout— so a tuned-lowrequest_timeoutpaired withrequest_min_throughputcannot abort a large, still-progressing chunked body. That bounds eachrecv()call in total rather than the body cumulatively across calls; the ILP server is trusted, unlike the identity provider, whose readsOidcDeviceAuth.parseBodyadditionally caps by total bytes and one monotonic deadline. A response that completes within the per-call bound is unaffected, while one that dribbles a single fragment for longer than it — previously tolerated as long as each socket read made progress — now aborts. The only no-argrecv()left is the construct-time/settingsprotocol-version probe, whose retry loop already catches the abort. Both flush-path consequences of that newly reachable abort are handled — see the third review round below.AbstractLineHttpSenderplumbs the token provider through with a deferred, retriable per-request pull (a throwing or blank-returning provider leaves the request token-pending for the next row instead of corrupting the half-built request), so the very first send already carries a provider-sourced token. Its error rendering now routes every untrusted server-supplied string throughputAsPrintable— the decoded JSON error body, the[http-status=…]field, and the line-protocol-version detection probe body — escaping control and Unicode format characters (bidi overrides, zero-width joiners, the BOM), so a hostile or proxied endpoint cannot reorder, hide, or forge the text shown in aLineSenderException(or spliced into a log line or terminal).QwpWebSocketSendersources its auth header from the token provider too, re-querying it on every connect/reconnect so a rotating token keeps a long-lived WebSocket sender authenticated.Tradeoffs and limitations
The origin pin behaves differently on the two construction paths.
fromQuestDB(...)discovery trusts an endpoint read from the issuer's.well-knownwherever the provider hosts it, so an off-origin provider (e.g. Google) signs in normally through a pinned issuer; only an endpoint the/settingsresponse itself advertised is held to the issuer's origin and path. The explicitbuilder().issuer(...)pin is stricter — a plain sanity check that both supplied endpoints sit on the issuer origin — so an off-origin provider configured that way must have its issuer omitted, or its endpoints supplied to match. This matches the Python client.A failed token pull is handled differently per transport, but neither drops buffered rows: over HTTP it leaves the request token-pending and retries on the next row; over WebSocket the initial handshake must obtain a token at
build()(it fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender (store-and-forward Invariant B). AgetToken()provider that fails only transiently recovers on both transports; a long WebSocket outage grows store-and-forward (and eventually applies backpressure) rather than ending the sender.The co-location check requires the token and device-authorization endpoints to share an origin. A test that previously pointed the token endpoint at a dead second port to simulate an unreachable endpoint was reworked to drop a co-located connection instead (
MockOidcServer.dropConnection()).The plaintext-channel pin's firing path is exercised end to end by reaching the loopback mock through a short-form
127.xaddress that the loopback classifier deliberately rejects as non-loopback. That trick relies on the OS resolver expanding the short form (BSDinet_aton, on Linux/macOS), which Windowsgetaddrinfodoes not do, so that one end-to-end test is skipped on Windows; the loopback classifier itself is covered cross-platform.Persistence writes a long-lived refresh token to disk in plaintext, protected only by file permissions — anyone who can read the file holds a credential until the IdP expires or revokes it. This is why persistence is opt-in; for at-rest encryption, supply a
TokenStorebacked by an OS keychain or a secrets manager instead ofFileTokenStore. On Windows POSIX permissions cannot be enforced, so the file currently relies on the user-profile directory's default ACL (owner-only ACL hardening is a follow-up); the client logs a one-line SLF4J warning atWARNthe first time it cannot enforce them — which an application with no SLF4J binding never sees, so this boundary has to be read here rather than watched for at runtime.Two stampede guards make a credential failure sticky for a few seconds.
getToken()runs once per ILP flush and once per WebSocket (re)connect, so a failing credential path would otherwise cost one token-endpoint round trip — or one blocking store read, two stack-trace fills and aWARNline — per flush, on the producer thread and under this instance's lock, which is enough to trip an identity provider's rate limits and lengthen the very outage being retried. So a silent refresh that fails is not re-attempted for 5 s: calls inside that window fail immediately with the cached-token-expired error instead of re-hitting the provider, and only a real attempt re-arms the latch, which an explicitsignIn()orclearCache()clears outright. ATokenStore.loadthat throws is retried once immediately (a one-shot fault, notably a carried interrupt flag, must recover on the next call), then backed off 5 s, doubling to a 60 s cap; a store that simply has nothing to return reports that by returningnulland is unaffected. Both are deliberately short — a stampede guard, not a circuit breaker — but the cost is that a credential which recovers inside a window is picked up on the first call after that window rather than the first call after it recovers.The HTTP chunk-size line is bounded where it is read. An overflowing chunk size was reading as valid framing (see the second review round below). The bound lives in
AbstractChunkedResponse, which counts significant hex digits and rejects more than 15 BEFORE parsing — the only form that works, since the worst residue (10000000000000000wrapping to zero, read as the terminal chunk) is indistinguishable from a genuine0afterwards.Numbers.parseHexLongkeeps its two's-complement contract:ffffffffffffffffis still-1, matchingparseHexIntbeside it and the server-sideio.questdb.std.Numbersof the same name, whoseLong256decoding depends on the wrap.io.questdb.client.stdis exported, so that contract is shipped and is deliberately left alone; a caller parsing a count a remote peer chose must bound the digits itself, which is what the chunk parser now does. The javadoc says so and points at it as the worked example.TokenStore.inLockmay now returnfalsewithout running the action. An implementation that waits for its lock must make that wait interruptible and abandon it on an interrupt, because the wait can outlast a caller's shutdown budget (see the second review round below). Thefalsereturn reads as "no refresh happened", whichOidcDeviceAuthalready handled, but a third-partyTokenStoreinherits the new obligation.A store-coordinated
getToken()may briefly wait to acquire the cross-process lock before a silent refresh (a few seconds at most forFileTokenStore— the acquire budget is capped — then it proceeds without the lock). It still never waits behind an interactive sign-in; this is a quick silent refresh, not an interactive wait.The
Response.recv(int)bound (see Supporting changes) also tightens existing, non-OIDC ILP flushes. Each flush-response body read is now bounded in total rather than re-arming its timeout per socket read, so a response that legitimately dribbles a single fragment for longer than the per-flush budget (request_timeoutplus therequest_min_throughputextension) — previously tolerated as long as each socket read made progress — now aborts. The bound is perrecv()call, not cumulative across the whole body, so it is a ceiling on one stalled fragment rather than on the response as a whole. A healthy response is unaffected. Making that abort reachable insideflush0's retry scope had two consequences, both fixed in the third review round below rather than accepted: a drain abort after a 2xx no longer re-sends a batch the server had already committed, and a body-read abort under an error status no longer reclassifies a definitive 401/403/405 as a transport failure. The pre-existing ILP-over-HTTP at-least-once window is therefore not widened; the only observable effect is that a pathologically slow single fragment is cut short instead of tolerated indefinitely.A malformed HTTP response head now fails the flush instead of being retried. This is the second change in this branch that existing, non-OIDC ILP-over-HTTP users can observe (the
Response.recv(int)bound above is the first).HttpHeaderParserrejects a response head past its fixed 4096-byte buffer, a malformedContent-Length, or a non-HTTP/1.x status line. Reaching one needs an intermediary — QuestDB's own/writeanswers 204 with a small head — but where one does, the flush now fails immediately with a non-retryableLineSenderExceptionnaming the malformed head, rather than spending the retry budget re-sending a batch the server had already answered. A healthy response is unaffected.The
JsonLexerescape decoding (see Supporting changes) also changes how a QuestDB row error renders. This is the third change in this branch that existing, non-OIDC ILP-over-HTTP users can observe, and the one they are most likely to meet — it is the ordinary bad-line rejection. QuestDB builds that error with a real newline (LineHttpProcessorState.formatError) andescapeJsonStrputs it on the wire as the JSON escape\n. The client used to copy those two characters through verbatim, soLineSenderException.getMessage()read...on line(s):\nerror in line 1: ...; the lexer now decodes the escape to a newline andputAsPrintablere-escapes it for display, so the same failure reads...on line(s):\u000aerror in line 1: .... Neither form lets a raw newline into the message — that is whatputAsPrintableis there for — and decodedmessage/code/line/errorIdfields are the point of the change; but the text an operator reads, or a log scraper matches on, is not the text it was.LineHttpSenderErrorResponseTest#testQuestDbRowErrorRendersTheDecodedNewlineAsAnEscapepins the new rendering. No assertion inquestdborquestdb-enterprisebreaks: each matches a substring that does not span the escape.The response-head read is bounded on elapsed time as well, and that is the fourth change existing, non-OIDC ILP-over-HTTP users can observe. Same mechanism as the first, one read earlier:
ResponseHeaders.await(int)now bounds the whole head read instead of re-arming its timeout per socket read, so a head that dribbles but keeps making progress aborts where base ran on with it. It precedes every response, including the 204 QuestDB's own/writeanswers with, so reaching it needs an intermediary exactly as the body case does. What separates it from the other two flush-path reads is how little it can conclude: at the point it aborts no status has been read, so unlike the 2xx drain — which knows the server committed and reports the success it was — and unlike the error arm — which has a verdict to surface — it says nothing about whether the batch landed. It falls toflush0's transport arm and is retried, which is the only answer available, and that retry spends the pre-existing ILP-over-HTTP at-least-once window: against a table without DEDUP keys, a peer that dribbles a head past the budget duplicates rows. The behaviour is kept — the alternative is the unbounded read the bound exists to remove — but the callsite now says so andLineHttpSenderErrorResponseTest#testDribbledResponseHeadFailsTheFlushWithinTheRetryBudgetholds it (restoring the per-read re-arm turns it red on the test timeout, which is what unbounded looks like from the flush path).Widening the lock-hold multiple turns a previously-accepted configuration into a
build()failure.FileTokenStore's staleness window must now exceed six timeshttpTimeoutMillisrather than four, sohttpTimeoutMillis(120_000)— the cap — paired with a default store (600s window) is rejected atbuild()where it used to be accepted, and the comment that blessed that pairing was wrong: a hold can reach 720s. The error nameslockStaleMillisand the fix is to raise it, but this is a break for anyone already on that pairing, and the only one in this branch that a caller's own configuration rather than their traffic can trigger. The default 30s timeout is unaffected (180s of a 600s window). The same figure capsacquireForGetToken()'s in-process wait, which grows with it — a peer waiting behind another instance's refresh now fails fast after six times the timeout rather than four.Tests & docs
OidcDeviceAuthTest(~123 cases) +MockOidcServer,BrowserLauncherTest,LineHttpSenderTokenProviderTest,WebSocketTokenProviderTest+TestWebSocketServer,SenderBuilderErrorApiTest,JsonLexerTest,LineHttpSenderErrorResponseTest,DisplaySafeTest,ChunkedResponseTest/ResponseTest,QwpQueryClientTokenProviderTest,FileTokenStoreTest,OidcDeviceAuthPersistenceTest,WebSocketCredentialCancellationTest,SenderPoolSfTokenProviderTest,BackgroundDrainerCredentialOutageReportTest,NumbersTest,HttpClientConstructorLeakTest; runnableOidcDeviceFlowExample/OIDCAuthExample; and README "OIDC Sign-In (Device Flow)" and "Persisting the Token Across Restarts" sections. Coverage includes:.well-knowndiscovery via a pinned issuer; a discovery document that omits the device-authorization endpointbuilder().issuer(...)origin pin rejecting off-origin endpoints; a/settings-advertised endpoint rejected when off the issuer origin; an endpoint discovered from the issuer's.well-knownaccepted even when off the issuer origin (the Google case)%2f), and a percent-encoded backslash (%5c) rejectedhttp(firing path skipped on Windows)audienceparameter discovered from/settingsand sent on the device and refresh requests429and a transient5xx/transport failure keep polling to the deadline; a terminal4xxand an OAuth error fail fast (including a429that also carries a terminal error);slow_downgrowth and the 60 s interval clamp; device-code-lifetime and clock-skew clampsEndpoint.parserejecting a malformed url: userinfo (user@host), a bracketed IPv6 literal, an out-of-range port, and control/whitespace/display-unsafe characters2,5) that must not be read as a 2xx/5xx classverification_uri_completethat sanitizes to empty treated as absentJsonLexerescape decoding, including a\uXXXXescape split across two parse fragments, and the lenient/exotic escape armsgetToken()failing fast while another thread holds the lock in an interactive sign-in or a silent refresh; native-memory cleanup on the error/rejection construction paths/settingsbody and a.well-knownbody under an HTTP error status refused as configuration, and a malformed status rejected without echoing its bytesTokenStorethrowing before its action degrading to exactly one uncoordinated refresh, and throwing after it keeping the completed refreshHttpClientconstructor rollback at four failure points, asserted throughassertMemoryLeak0600/0700permissions and control-char JSON escaping; a corrupt, empty, oversized, schema-version-mismatched, or per-field fingerprint-mismatched file ignored; a tampered far-future expiry clamped and a CR/LF served token rejected on load; a restart serving a valid persisted token (or silently refreshing an expired one) without re-running the device flow; rotating vs non-rotating refresh write behaviour; a swallowed save not replaying a revoked token; the cross-process lock-file protocol (acquire, mutual exclusion, stale-steal, degrade);getToken()degrading to a lock-free refresh when a peer holds the lock; the HTTP-timeout cap; the file-name hash pinned as a cross-language contractclear()reclaiming a write temp stamped in the future; a dribbled response head failing an ILP flush inside its retry budgetReview follow-ups
A level-3 review of this branch surfaced the issues below; all are fixed here, each production fix with a regression test proven to fail without it (see the commit history for detail).
Confirmed defects:
adopt()/storeTokens()accepted a whitespace-only served token (it passedisEmpty()/hasOnlyTokenChars()vacuously), sosignIn()reported success andgetToken()served a blankBearerheader the server only answers with 401, never falling back. Now rejected viaChars.isBlank; a blank served kind is folded to absent soselectToken()surfaces the actionable error.getToken()lock contention: the unconditionaltryLock()failed fast on any lock hold, so concurrent callers sharing oneOidcDeviceAuththrew on every token refresh. It now waits briefly behind a peer's silent refresh (bounded byhttpTimeoutMillis) and fails fast only behind an interactive sign-in.Hardening and coverage:
{"config":[{...}]}can no longer surface fields at the trusted config depth.validateTokenstill re-scans every pulled token by design - a provider may mutate a reused buffer between flushes - but that scan is O(token length), runs once per flush, and is dwarfed by the network round-trip.)parseHex4non-ASCII guard, the raw..issuer-path reject, theFileTokenStoresize caps, the store-and-forward credential-timer reset, and the ILP flush whole-read timeout bound.HttpTokenProvider.getToken()now discloses the OS-bounded connect stall;TokenStore.inLockdocuments its no-reentrancy contract;FileTokenStorestates the concurrent-refresh residual (token-family revocation on a reuse-detecting IdP) instead of understating it.Review follow-ups (second round)
A further review round surfaced the issues below. As before, every production fix carries a regression test shown to fail without it; the commit messages record the counterfactual output for each.
Confirmed defects:
storeTokens()kept the current refresh token whenever a response omitted one, for refresh grants and fresh device grants alike. For a refresh that is right — RFC 6749 §6 makes the field optional and the same authorization is continuing — but a device grant is a new authorization and may be a different human. So user A's refresh fails, user B completes the device flow without a refresh token, B's access token expires, and the next silent refresh presents A's retained token and resumes as A: no prompt, no error, nothing in any log recording that the identity changed. Persisted it is worse — the refresh token is unchanged, sopersistIfRotated()sees no rotation and skips the save, leaving A's whole entry on disk for the next process start to adopt. The omission policy is now grant-specific: a device grant that returns no refresh token clears it, sogetToken()asks for an interactive sign-in instead. Covered end to end, including a restart over the same store.closeTraffic()cannot reach it; the only lever is an interrupt, sent by the send loop's connect cancellation on the foreground path and byBackgroundDrainerPool'sshutdownNow()on the orphan-drainer path. Neither reached the built-in path: the in-process lock was taken withlock(), and the lock file was polled throughOs.sleep, which catchesInterruptedExceptionand keeps sleeping to its own deadline. Since the acquire budget caps at 30 s — the same as the QWP shutdown budget — a sender closing while another same-identity instance held the lock burned the whole budget and then gave up on its I/O thread, delegating teardown of the native client, the cursor engine and the store-and-forward slot lock; on the drainer path it abandoned the drainer still holding the orphan slot's lock.inLocknow takes the process lock interruptibly, polls withThread.sleep, and checks for an interrupt before running the critical section. Every prior test blocked the pull in an interruptible test double, so the shipped path was untested; the new tests block it in a realOidcDeviceAuthover a realFileTokenStorewhose lock a peer holds.val << 4accumulation wraps, and the least harmful — a negative size matches neither the data branch nor the terminator, so the state machine spins, which is at least visible. The other two report success with the wrong bytes:10000000000000000wraps to zero and reads as the terminal chunk, so the caller gets a complete-looking body that is truncated and the connection's framing is lost for the next keep-alive response, while a longer value wraps to a short positive count that mis-frames everything after it. Truncated JSON parses. The size line is chosen by the server, untrusted for a discovery or token response. The size line is now bounded in the chunk parser itself, before the parse, so all three residues are refused at the point the untrusted digits are read — see the chunk-size note in Tradeoffs.credential-unavailablereport was dispatched into a null; at initial connect the exception matched none of the typed arms and landed in the generic transport arm, whose warning says the cluster is unreachable. A revoked token therefore surfaced as a network fault while rows accumulated in store-and-forward, pointing an operator at disk sizing rather than at their credentials. The sink is now wired and the condition named.TERMINALis filtered on the way through: on an orphan loop a terminal is the loop handing the slot back to the drainer to decide, so forwarding it would announce a dead producer for a rotating credential the next sweep accepts, and double-report every quarantine.Exported API compatibility:
module-info.javaexports and that ship a javadoc jar, with no japicmp gate to catch it:Response.recv(int)arrived as an abstract interface method, twoQwpWebSocketSender.connect(..., String, ...)overloads were retyped toSupplier<String>, and the multi-hostAbstractLineHttpSender.createLineSendergained a parameter in place. An existing caller would fail withNoSuchMethodError, an externalResponseimplementation withAbstractMethodError. No affected caller exists in this repo, questdb, or questdb-enterprise, so this is a latent break rather than an observed one. The exact old signatures are restored as delegates; the supplier-backed connect entry points are renamedconnectWithCredentialSupplierrather than left as overloads, because aStringand aSupplier<String>parameter of equal arity make a barenullcredential argument ambiguous and would trade a link error for a compile error. Verified by compiling a caller written against the pre-branch signatures against both the unfixed and fixed classes.Coverage:
SenderPoolapplies the provider on two legs and only the non-SF one was exercised. Unwired, every SF pooled sender's upgrade would go out unauthenticated and take a 401, surfacing later as ring backpressure or a quarantined slot rather than at connect time; on the recovery leg a recovery delegate replays the previous run's data, so it would quarantine the slot and reportDATA_LOSSfor replayable rows..failedsentinel that nothing in production clears, permanently abandoning replayable rows over a token the next pull would have refreshed, while the other direction only delays the operator's signal.isCredentialDynamic()exposes the tag on a built sender, asserted alongside the header the server actually received and the value the drainer's reconnect factory reports.Review follow-ups (third round)
A third review round surfaced the issues below. As before, every production fix carries a regression test shown to fail without it; the commit messages record the counterfactual output for each.
Confirmed defects:
fetchJsonawaited the response headers and went straight to parsing, so both discovery paths read configuration out of a non-2xx body. A discovery document decides where the user signs in and where the long-lived refresh token is POSTed, and an error body can easily carry the keys — an error envelope, a proxy's branded page, a captive portal, a tenant-not-found stub. Black-box proofs constructed a working instance from an HTTP 500/settingsresponse and from an HTTP 404.well-knownresponse. The token and device-authorization paths already gated on status; this one did not.requireSuccessStatusnow runs beforeparseBody: it validates the status is exactly three bare digits before echoing any of it — the header parser copies the status-line token verbatim apart from SP/CR/LF, so a non-digit byte means a malformed or hostile line that must not splice ESC or other control bytes into a message, a log or a terminal — and then requires a leading2. A short all-digit status is malformed too and must not be read as a class by its leading digit, matchingisHttpStatusSuccesselsewhere in the class. On rejection the body is drained within the usual bound so the keep-alive connection stays usable, and the connection is dropped when the drain cannot finish, mirroringreadResponseon the token path. Each call site passes its own message, so a/settingsfailure and a.well-knownfailure are told apart.flush0sit inside thetrywhose only catch treatsHttpClientExceptionas a retryable network error. Base could not throw there for a dribbling-but-progressing server, becauserecv()re-armed its timeout on every socket read; bounding the whole call (see Supporting changes) made it reachable, and the two branches fail differently. On the success branch a 2xx is the commit — the server already has the rows — so draining its body afterwards is only bookkeeping to keep the connection reusable, and an abort there re-sent a batch the server had accepted, with a retry budget that kept trying. The drain is now wrapped: on abort the connection is dropped, since unconsumed bytes would mis-frame the next response, and the flush is reported as the success it was. On the error branch the status is the verdict and the body is only detail for the message; an abort escaping into the catch reclassified a definitive 401, 403 or 405 as a transport failure, burned the whole retry budget against an endpoint that would keep refusing, and finally reported "Connection Failed: timed out" with the real status nowhere in it.throwOnHttpErrorResponsenow wraps its body reads — all four branches at once — and falls back to a status-only exception. Reaching either needs a chunked, slowly dribbled body, which QuestDB's own/writedoes not produce (it answers 204 non-chunked), so exposure is through intermediaries. The pre-existingtestFlushResponseBodyDribbleAbortsOnRequestTimeoutasserted that a dribbled body fails the flush, against a mock that answers 200 — so it was pinning this defect rather than guarding against it. It is reworked rather than left in place: it still proves the whole-read bound, since an unbounded read would hang to the test timeout, and now also proves the batch is sent exactly once, against a retry budget a re-send would visibly spend.TokenStoretook the whole sign-in down with it.TokenStoreis a user-implemented SPI and persistence is documented best-effort, buttryRefreshCoordinatedcalledinLockbare, so a store that threw before running its action refreshed nothing even though the client held a perfectly good refresh token. What the right degrade is depends entirely on whether the refresh already ran, which only the action can report, so the call now tracks whether it entered and completed. Threw before the action: nothing was refreshed, so run one — exactly one — uncoordinated refresh; the point of the lock is that a rotating refresh token must not be POSTed twice, and a reuse-detecting provider answers a replay by revoking the whole family. Threw after the action completed, releasing a lock or closing a handle: the refresh happened and the token is live, so report what the action returned; re-running it is that same double-POST, and throwing tells the caller a completed sign-in failed. The action itself threw: that is the refresh's own failure, not the store's, so it propagates untouched — never swallowed, never replayed.Erroris deliberately not caught; anOutOfMemoryErroris not a store fault to degrade around.FileTokenStorealso let unchecked exceptions escape its own lock bookkeeping —SecurityExceptionfrom a SecurityManager,UnsupportedOperationExceptionfrom a filesystem that cannot carry POSIX permissions — so the acquire path now degrades to lock-free on those as it already did onIOException, and the release path, which runs in afinallyafter the critical section, absorbs them so bookkeeping cannot replace a completed result. The guard above already contains such an escape, so this second half is about the quality of the degrade — coordination is kept for that refresh rather than lost — and about the reference implementation honouring the contractTokenStore.inLockpublishes.Pre-existing defect newly exposed:
HttpClientconstruction leaked when it failed partway. A constructor that fails partway leaves an object nobody can close: it never reaches the caller, so nofinally, no try-with-resources and noclose()ever runs on it, and whatever it had already taken is lost for the life of the process.HttpClient's base constructor takes a socket and two native buffers, then each platform subclass builds its poller, and neither step guarded the earlier ones. What makes it worth fixing is the trigger:epoll_createandkqueuefail on fd exhaustion, and the mallocs fail under memory pressure, so the failure arrives exactly when resources are already scarce, and a caller that retries compounds the loss each time. The root predates this branch; OIDC discovery newly exposes it by building a client per fetch. The base constructor now stages the socket and both buffers in locals, assigns the fields only onceResponseHeadershas succeeded, and frees in reverse order undercatch (Throwable); each platform subclass wraps its poller construction and callssuper.close()before rethrowing. Kqueue already guarded its own constructor this way, so this is the same pattern applied one level out; Epoll, Kqueue and FDSet each free their own allocations on failure already, and what leaked was purely what the caller had taken before calling them.HttpClientConstructorLeakTestcovers four failure points throughassertMemoryLeak— removing the rollback leaks 65536 bytes on the base path and 131072 on the poller path. The base case injects a negative response-buffer size and runs everywhere; the poller cases areAssume-guarded so only the running platform's executes, leaving epoll and FDSet to CI, which the class javadoc records. The pollers are failed through their facades rather than through a failing size, even though a size would need no facade: Kqueue's own failure path callsclose()with its descriptor still zero, so an allocation failure there would close the test JVM's stdin — a facade returning a negative descriptor is the shape fd exhaustion actually takes and touches no real descriptors.Review follow-ups (fourth round)
A fourth review round surfaced the issues below. As before, every production fix carries a regression test shown to fail without it; the commit messages record the counterfactual output for each.
Confirmed defects:
PoolHousekeeper.stop()andSenderPool.stopStartupRecoveryDriver()escalate toThread.interrupt()when their join times out, to break a recovery build's credential pull — and the thread they interrupt is the same one that then runssenderPool.reapIdle()and the startup-recovery step'sfinally, both of which close a delegate.CountDownLatch.await(t, u)testsThread.interrupted()before it ever consults the latch, so the shutdown await returned having waited 0 ms,close()took the failed-stop branch, and the slot was reported with its flock still held — precisely the outcome the interrupt was added to prevent. That branch re-asserts the flag, so in a reap sweep every remaining delegate failed the same way andQuestDB.close()returned still holding their slots.QwpWebSocketSender.close()is now interrupt-neutral, the shapeQwpQueryClient.close()andQueryWorker.shutdown()already use — the query half of the pool got that treatment when the escalation landed; the ingest half, whichreapIdle()reaches first, did not. An interrupt delivered during the close still takes the failed-stop branch, which is correct.PoolHousekeeper's comment claimed every wait on the pull path is interruptible; the token POST's connect, send, await and parse run on the native HTTP client, which no interrupt breaks, so it now says what the escalation does and does not buy.HttpExceptionhad been added toflush0's transport catch, which was right about the disconnect and the exception type — uncaught it escapedflush0entirely, leaving the next flush on a connection holding a half-read response and throwing a rawHttpExceptionpast every caller'scatch (LineSenderException). But it must not be retried.HttpHeaderParseronly runs on bytes that arrived, so the exception is positive evidence the server answered — the same evidence the 2xx drain arm treats as decisive — and the head is chosen by an intermediary, so the next attempt parses the same block and fails identically. Measured against a mock returning a 5000-byte head: 16 sends over 10.8 s per flush at the default retry budget, where the same trigger sent once before. It now disconnects, reports a non-retryableLineSenderExceptionnaming the malformed head, and does not re-send. The existing test asserted the retry, so it was pinning this rather than guarding against it.maybeLoadFromStore()deliberately leaves its latch unset when a read throws, so a transient fault is retried — but it runs at the top ofgetToken(), ahead of the cache check, andadopt()assigns the served kind, the expiry and the ttl unconditionally, never comparing the file against what is already in memory. A store directory unavailable acrosssignIn()and readable afterwards (an unmounted home, a container started before its volume attaches) therefore undid it: the read failed, the human authenticated, the save failed the same way and was swallowed, and the nextgetToken()— one per ILP flush — installed the previous entry over the grant just obtained.persistIfRotated()now latches the flag, above the rotation check so it holds whether or not the save succeeds, and covering the refresh-only path throughadoptRotatedRefreshToken()with the same line. A store that never yielded a token is unaffected.adopt()refuses to read back.adopt()rejects a refresh token carried with neither token kind as positive evidence of a foreign writer — the guard that stops someone who can write the store directory swapping in their own refresh token. Its justification rested on a callsite count ("persistIfRotatedruns solely at the tail ofstoreTokens"), and that count was wrong: undergroupsInToken, a stored entry carrying only an access token takesadopt()'s own served-kind-absent branch, which nulls both kinds and keeps the refresh token, so a refresh that rotates the refresh token but still returns no id token reachesadoptRotatedRefreshToken()→persistIfRotated()with both null. The file it wrote was one it would never read back, so every restart re-ran the device flow over a live refresh token on disk.persistIfRotated()now declines that shape; the previous entry stays, its burned refresh token costs one silent round trip on the next start, and the rejection keeps its teeth.Review follow-ups (fifth round)
A fifth review round found no new defect in the shipping code. It produced coverage for two paths that ran only in production, two accuracy fixes, and one candidate that was examined and deliberately left as it is.
Coverage:
stealIfStalecaptures a lock it judged stale into a private name, then re-reads it to confirm it captured the lock it judged rather than one a peer recreated in the gap. When that check says no, the capture has to go back — hard-linked rather than renamed, so a third party that claimed the freed path keeps its live lock, and byte for byte, becausereleaseLockverifies the owner stamp before deleting. None of that ran under test. Reaching it throughstealIfStaleneeds a peer to replace the lock file between the staleness read and theATOMIC_MOVE, an interleaving no test can force without a production seam, sotestConcurrentStealersLeaveExactlyOneWinneronly ever drove the confirmed-stale path — and its three observables (no stealer threw, the lock is gone, no capture temp survives) all hold under the baredeleteIfExists(lock)that the method's own comment says must never be used, because a bare delete also removes the lock and leaves no temp. So roughly thirty lines guarding a peer's live lock could be deleted or inverted without a red test, and the failure they prevent is two holders POSTing the same rotating refresh token, which a reuse-detecting identity provider answers by revoking the whole family. The restore is split intorestoreCapturedLock(lock, captured)— pure code motion — and driven directly by two deterministic cases that need no interleaving: one asserts the peer's owner stamp survives byte for byte, the other has a third party already holding the path and asserts its lock is untouched. Dropping the capture instead of restoring it fails the first; replacingcreateLinkwith aREPLACE_EXISTINGmove fails the second.test_a_failed_connect_oidc_leaves_the_protocol_in_syncclaimed to drive the newline case through a real failure. It cannot: the connect it aims at a closed port fails insidefetchJson, which throwsOidcAuthExceptioncarrying a fixed literal, and that class builds every message from literals plusputSanitized, which strips CR and LF —Throwable.toString()never appends the cause either. So the reply it inspects has no newline to remove whether or not the OIDC verbs sanitize, and both its assertions hold with the sanitizing deleted (the second,pulls == 0, is true by construction on a fresh sidecar as well).QwpSidecarErrReplyTestnow supplies a multi-line message directly — the nested-cause-and-stack-frame shape aThrowableactually carries a break in — and fails whensanitize()is dropped or when a null message renders as the wordnull. The interpolating ERR replies route through oneerr(out, message)helper that sanitizes, prints and flushes, so a new verb inherits the sanitizing instead of having to remember it; the ERR replies left writing directly are fixed literals with nothing to interpolate. The e2e test's docstring now claims only what it proves: that a real device-flow failure replies ERR and leaves the stream in sync for the next command.Accuracy:
AbstractResponse.recv(int)andAbstractChunkedResponse.recv(int)now carry@Override, which therecv()beside them in the same two classes already had. It matters for this pair specifically: the two methods delegate in opposite directions, so an implementation whose signature drifts from the interface does not fail to compile — it silently inherits the default that discards the bound and defers torecv(), andrecv()here is implemented asrecv(defaultTimeout), which recurses.OidcDeviceAuthTlsTest's javadoc named the partial-record read as its reason for existing. It does not script one — both canned responses are a few hundred bytes and arrive whole, sorecvOrDienever returns 0 without consuming its timeout, and removing the whole-call bound leaves all three assertions green. It now states what it does cover, which is real and worth having (the whole device flow — handshake, record framing, two JSON bodies read back — over a real TLS socket, the only shape a production sign-in takes and one nothing else exercised), and names where the bound is actually pinned:ResponseTest#testRecvHonoursTotalTimeoutWhenNoApplicationBytesArriveandChunkedResponseTest#testRecvHonoursTotalTimeoutWhileChunkSizeDribbles. That matters becauseOidcDeviceAuthTransportBudgetTestpoints this way for the end-to-end half, and a reader following the pointer would otherwise arrive and find nothing.Examined and deliberately left unchanged:
connectWithDurableAckRetry()clearsfirstDynamicCredentialAuthFailureNanoson its three transient arms but not on a successfulclientFactory.reconnect(), so the anchor keeps ageing across the wire session that follows — and a session that durably acks nothing never reachesnoteAckProgress()either. A later 401 can therefore arrive with the attempt threshold already met from an earlier run and a dwell measured from a rejection that ended minutes ago, and quarantine on the first sweep of what should have been a fresh ride-out. Clearing the anchor on a successful connect is the obvious fix and is the wrong one:testFlappingCredentialEscalatesAcrossMidDrainRecyclespins the opposite contract — an accepted connect does not end a run of rejections; only real ack progress does — and the change turns a flap that quarantines after six rejections into one that quarantines after 256, reopening in slower motion the recycle-forever hole the field promotion closed. Closing the gap properly means routing the loop's own transient observations through to the drainer's anchor, which trades a longer ride-out for a later operator signal. That is a design decision rather than a bug fix, and the current behaviour is in any case strictly better than the pre-branch one, which quarantined on the first 401 unconditionally.Review follow-ups (sixth round)
A sixth review round found one defect in the shipping code, one bounded cost, one arithmetic error in a safety bound, one documentation gap with a security consequence, and two paths whose guards no test could fail on. It also corrected an overstatement this branch's own analysis had made.
Fixes:
AWTErrorescaped both guards on the browser launch and abortedsignIn().BrowserLauncher.open()caughtExceptionandDeviceCodePrompt.openBrowser()catchesLinkageError;java.awt.AWTErrorextendsErrordirectly and is neither, so it passed through both.Toolkit.getDefaultToolkit()raises it wheneverassistive_technologies— from$JAVA_HOME/conf/accessibility.propertiesor the matching system property — names a class the runtime cannot load, which is the stock configuration on several Linux distributions that point atorg.GNOME.Accessibility.AtkWrapperwithout shipping the package; a setDISPLAYthat answers no X server reaches the same error by another route.Desktop.isDesktopSupported()calls the toolkit unconditionally with no headless short-circuit, so the default prompt met it on the way to a best-effort browser open, and an interactive sign-in died with an AWT error after the verification URL and code had already been printed — as a type the caller's documentedcatch (OidcAuthException)does not handle.open()now catchesThrowable, rethrowingLinkageErrorfirst so the missing-java.desktopdegrade stays whereDesktopFreeModulePathTestpins it. Two offsets already existed and still apply:-Djava.awt.headless=trueskips the assistive-technology loading entirely, andquestdb.client.oidc.open.browser=falsereturns beforeDesktopis touched.BrowserLauncherAwtErrorTestdrives the real prompt in a forked JVM whose toolkit cannot initialise; it forks twice because the failure is one-shot (the toolkit throws on the firstgetDefaultToolkit()and then completes, so a probe sharing the process would consume the only throw), and the probe half is also what keeps the launch half from reaching a real browser on a developer machine. Reverting the guard tocatch (Exception)turns it red.LOCK_HOLD_HTTP_TIMEOUT_MULTIPLEsizes the worst-case time a coordinated refresh holds the token store's cross-process lock, andbuild()enforces it as the floor underFileTokenStore's staleness window; a hold that outruns that window is judged abandoned, so a peer steals a live holder's lock mid-refresh and both processes POST the same rotating refresh token. It was 4, counting send, await, parse and the parse-failure drain. The comment beside it saidhttpConfig()bounds the connection phase "byhttpTimeoutMillistoo", butHttpClientspends the TCP connect and the TLS handshake as separate budgets — it anchors a freshtlsHandshakeStartNanosand grants the handshake its ownconnectTimeoutrather than continuing the connect's. Six is the count of independently bounded phases. See the tradeoff below for what widening it costs.run()'s finally closes the loop error dispatcher this branch added, on a thread whose interrupt flagstopRequestedOrInterrupted()leaves set soloop.close()'s latch await throws rather than blocking on a wedged I/O thread.SenderErrorDispatcher.close()drains by joining its delivery thread against a refreshed deadline and re-asserts the flag in its catch, soThread.join(millis)threw on arrival on every pass and the loop never parked. The scope is narrower than it first looked, and the earlier draft of this note overstated it:join()returns normally the moment the thread is no longer alive, so neither the wait's duration nor which errors get delivered changes — measured, an interrupted and an uninterrupted close of the same dispatcher finish within a millisecond of each other and reach the same verdict on whether the thread survived. The cost is CPU alone: 15k–53k join attempts measured, one core pinned for that window, per closing drainer, withmax_background_drainersdefaulting to 4, on shutdown only. Clearing the flag around that one call and restoring it afterwards is the same clear-and-restoreQueryWorker.shutdown(),QwpQueryClient.close()andQwpWebSocketSender.close()already use, which is why the pre-existing foreground caller never hit it. No test: the spin is invisible to wall-clock and to delivery, so the only discriminator is CPU time, and a threshold assertion on that would be more fragile than the three lines it guards.Os.sleepdestroyed an interrupt in the store's rename retry.replaceTarget()backed its retry off withOs.sleep(), which catchesInterruptedException, sleeps on to its deadline and never re-asserts the flag — the hazardacquireLock()'s poll in the same file already avoids and documents in as many words.save()parks and restores only the flag it saw on entry, so an interrupt arriving mid-save had to survive this sleep on its own, and that interrupt is whatPoolHousekeeper.stop()delivers to break a recovery step blocked in a credential pull. Swallowed, the stop signal is gone: the housekeeper's second join times out andclose()can return with the recoverer still holding its store-and-forward slot flock, so an immediate reopen fails with "sf slot already in use". The retry now sleeps interruptibly, re-asserts the flag and abandons; the denial it was retrying still propagates, and persistence is best-effort either way.testReplaceTargetPreservesAnInterruptDeliveredDuringItsBackoffdenies the rename the way its sibling does and proves the denial bites on the host before resting on it.Documentation:
tlsConfigalso governs identity-provider certificate validation, and nothing said so.Builder.tlsConfig()carried no javadoc at all, while the twoallowInsecureTransport()javadocs beside it — and the README — promise that the identity provider endpoints are "never relaxed" and that the device code and refresh token "never cross the network in cleartext". That is true of the scheme and silent about the trust anchor: oneOidcDeviceAuthholds a singleClientTlsConfigurationand threads it through the/settingsfetch, the.well-knownfetch and the device-authorization and token POSTs alike, so a user who reaches forINSECURE_NO_VALIDATIONto talk to a QuestDB server with a self-signed certificate also stops the client authenticating the token endpoint — the leg that carries the refresh token — while the neighbouring sentence tells them that leg is protected. BothtlsConfig()setters, bothallowInsecureTransport()setters and the README's OIDC section now say what the scope is and recommend a trust store over disabling validation whenever an identity provider is in play. No behaviour change;INSECURE_NO_VALIDATIONstill means what its name says.Coverage:
noteAckProgress()'s guard was unpinned, and the wholeBackgroundDrainersuite stayed green without it. The method ends an escalation episode when the wire durably acks something past the watermark. The delivering case had a test; the non-delivering one did not, andrun()'s poll loop calls the method on every 50 ms tick — so weakeningacked <= watermarktoacked <, or dropping the guard, refilled all three escalation counters twenty times a second for as long as the drain stayed connected, and an orphan drainer swept forever with no ack progress: no.failedsentinel, noDATA_LOSSreport, the slot lock held and one ofmax_background_drainersworkers pinned for the life of the process.testConnectingWithoutDeliveringDoesNotGrantAFreshSettleBudgetis the negative twin of the delivering test — the same two gap windows, neither reaching the threshold alone, with a session between them that connects, takes a frame and closes without acking — and expects the quarantine. The weakened guard turns it red.QueryWorker.shutdown()'s interrupt handling had no test, while both its siblings did.testShutdownHandsBackACarriedInterruptpins the hand-back and the thread teardown; removing the restore turns it red. It deliberately does not pin the clear: the only observable difference there is whetherjoin()waited, andthread.interrupt()fires immediately before it, so the dispatch thread is already leaving and any "still alive on return" assertion would be a race rather than a check. Pinning it honestly needs a dispatch thread with a controllable exit latency, which is a production seam this does not justify.Housekeeping:
isRetryable, the twoMIN_*constants,requireSecureIdpEndpoint/requireSecureTransport,warnPersistence, and thereadBounded..retainProcessLockrun inFileTokenStore, where lifting the first two stranded two more). Pure moves.invokeIsLoopbackHost, a reflection helper that went away when abuild()-driven test replaced it, one of them stranded above an unrelated stack-inspection helper.CursorWebSocketSendLoop'scancel()claimed the credential-pull interrupt "fires ONLY while a pull is in flight, so a sender with no token provider is untouched"; the connect walk publishes that marker whenever it carries a cancellation, before it looks at the supplier, so a background sender with no provider configured is in the window too. It costs nothing —close()setsrunning = falsebeforecancel(), so every path a late interrupt can reach is already winding down and the I/O thread's exit is native frees plus a latch countdown — but the comment now says that rather than denying the window exists.Review follow-ups (seventh round)
A seventh review round found no Critical defect — both the correctness and the test gate passed — and six Moderate ones, all fixed here rather than deferred. As before, every production fix carries a regression test shown to fail without it, and the commit messages record the counterfactual output for each. The whole set is 6 production edits across 4 files; the rest is test and spec.
Confirmed defects:
.untrustedsentinel carries the "another local user could have planted an entry here" verdict across the chmod that erases the evidence, and both halves of it read the name in a way that fails OPEN — against the one party who can write that directory and therefore choose what stands at the name.restrictToOwnertested!Files.exists(sentinel), which follows symlinks, so a dangling link planted at the name reported absent and the directory read as trusted on its permission bits alone: no race to win, nothing in any log, for as long as the link stood.markUntrustedcould not repair it either, because its exclusive create answersFileAlreadyExistsExceptionfor a symlink exactly as it does for a peer's mark, so it read the squatter as "already marked". The verdict is nowFiles.notExists(sentinel, NOFOLLOW_LINKS)— positive evidence of absence, so a link, a directory or an indeterminate stat all leave the directory distrusted — andmarkUntrustedtreats only a regular file as a mark, displacing anything else. Both rules are in the frozen cross-language contract now, so the Python client mirrors the fix rather than the bug.markUntrustedonly runs while the directory is still other-writable, which the chmod has ended. So every laterload()returns null over an entrysave()has just written, for the life of the directory, and the only line that appeared reported a different condition with a different fix. One entry this process cannot unlink is enough (another UID under a sticky-bit parent, a persistentEPERM/EIO/ESTALE), and a non-empty directory squatting the sentinel's name reaches the same state and survives the chmod. The distrust stays — it is the fail-closed direction — but both paths now warn once per JVM, naming the fault kind and the entry to remove. A headlessgetToken()consumer that re-prompts every restart is the case persistence exists to serve, and its operator had nothing to act on.tryRefresh()has two branches over one clean 2xx, and only one applied the ruleadoptRotatedRefreshToken()exists to state: a 2xx with no OAuth error means the provider ACCEPTED the token we presented, so a rotating provider has already burned it and therefresh_tokenin that body is the live one. The branch where the served kind is absent took it; the branch where it arrives and is unusable — rejected byvalidateTokenChars— returned first and dropped the rotation, leaving the spent token cached. This branch made that reachable: before it,JsonLexerdid not decode escapes, soACCESS\r2arrived as nine printable characters and the rotation was recorded.signIn()hides the loss because its device-flow fallback overwrites the refresh token;getToken()is where it bites, because it never prompts — the spent token goes back on the wire on the next flush, and a reuse-detecting provider answers a replay by revoking the whole family, which with aTokenStorereaches every process sharing the identity. The catch now adopts the rotation before returning.clear()'s "at ANY age" temp sweep was conditional on a clock. A crash betweencreateTempFileand the atomic rename orphans a temp holding the full serialized entry — access, id and refresh tokens in plaintext — andclear()passesminAgeMillis0 to reclaim it however fresh it is. That went through the samenow - mtime >= minAgeMilliscomparisonsave()'s staleness-bounded sweep uses, and the comparison is not age-neutral at zero: an mtime ahead of now yields a negative left-hand side, which is not>= 0. So the one sweep written to ignore the clock was the one a clock could veto, andsave()'s sweep skips the same file against a larger threshold — nothing in the class would ever reclaim it. A future mtime needs no attacker: a network home whose server clock leads the client's (already documented as in scope), or any wall-clock step backwards between the crash and the clear. The sweep now short-circuits onminAgeMillis <= 0.Documentation:
BackgroundDrainerPool's javadoc described a fast path this branch removed. It states that an interruptedclose()skips the graceful window and shuts the executor down hard. That stopped being true whenQwpWebSocketSender.close()was made interrupt-neutral — it has to be, because a carried flag made every wait beneath it throw on arrival, which is how a reap sweep came to report slots with their store-and-forward flock still held.drainerPool.close()runs inside that window, so only an interrupt delivered during the wait still takes the fast path, and the common caller (a task cancelled byExecutorService.shutdownNow()) arrives with one rather than delivering one. A cancelled close therefore spends up toGRACEFUL_DRAIN_MILLIS + STOP_GRACE_MILLISper sender whose orphan drainer is actively delivering. The behaviour is right and stays — the split stop already exempts drainers that are only retrying a connect, so what the window waits on is a drainer with rows on the wire — and the javadoc now says which of the two interrupts it means.Tandem
OSS: questdb/questdb#7331
Ent: https://github.com/questdb/questdb-enterprise/pull/1090