Skip to content

feat(core): OIDC sign-in via device flow (RFC 8628) - #52

Open
glasstiger wants to merge 108 commits into
mainfrom
ia_oidc_device_flow
Open

feat(core): OIDC sign-in via device flow (RFC 8628)#52
glasstiger wants to merge 108 commits into
mainfrom
ia_oidc_device_flow

Conversation

@glasstiger

@glasstiger glasstiger commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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.

import io.questdb.client.Sender;
import io.questdb.client.cutlass.auth.OidcDeviceAuth;

// Discover the client id, scope and endpoints from the QuestDB server's /settings:
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
    auth.signIn(); // sign in once: prompts on first use, then caches and refreshes

    // Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each
    // request, so a long-lived sender keeps working as the token rotates. getToken() refreshes
    // silently and never prompts on the flush path.
    try (Sender sender = Sender.builder(Sender.Transport.HTTP)
            .address("questdb.example.com:9000")
            .enableTls()
            .httpTokenProvider(auth::getToken)
            .build()) {
        sender.table("trades")
                .symbol("symbol", "ETH-USD")
                .doubleColumn("price", 2615.54)
                .atNow();
    }
}

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, an allowInsecureTransport opt-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 full Bearer … value; clearCache() drops the cached token so the next signIn() 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 a ReentrantLock; getToken() uses tryLock and fails fast rather than wait behind an interactive sign-in. Token state is in-memory only by default; pass a TokenStore to persist it across restarts (see Token persistence below).

Sender integration — new HttpTokenProvider interface and Sender.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 fixed httpToken(...), which is captured once and eventually starts returning 401s. Mutually exclusive with httpToken/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 when build() runs (the initial handshake fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely, with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender, just as a persistent transport reconnect failure does not (store-and-forward Invariant B). The first pull is deferred off the build path to the first row, so the documented construct → signIn() → send ordering works and a provider that throws leaves the request retriable instead of corrupting the sender.

QWP egress query clientQwpQueryClient.withBearerTokenProvider(HttpTokenProvider) accepts the same on-demand provider, so OidcDeviceAuth::getToken plugs into the egress query path as well as ingress. The provider is queried at every WebSocket upgrade — the initial connect() 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 with withBearerToken/withBasicAuth.

DeviceCodePrompt / DeviceAuthorizationChallenge — how the verification URL and user code are shown. The default, DeviceCodePrompt.openBrowser(), prints the instructions to System.out and also tries to open the verification URL in the local default browser; the browser open is best-effort (skipped on a headless JVM, without the java.desktop module, or for a non-http(s) URL, and disabled by -Dquestdb.client.oidc.open.browser=false) and never blocks or fails sign-in. Use DeviceCodePrompt.SYSTEM_OUT to print only, or supply your own to render a clickable link or a QR code, e.g. in a notebook.

audiencebuilder().audience(...) / discovered from acl.oidc.audience. When set, the audience parameter is sent on the device-authorization and refresh requests, for providers that require it to stamp the aud claim QuestDB expects.

The token can be presented to QuestDB over any auth path the server already validates:

  • REST / ingestionAuthorization: Bearer <token>.
  • PG-wire — connect as _sso with the token as the password (requires acl.oidc.pg.token.as.password.enabled=true on 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 optional DiscoveryOptions.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 /settings advertised is constrained to the issuer, while an endpoint read from the identity provider's own .well-known is trusted wherever the provider hosts it:

  • .well-known discovery 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-supplied issuer, never from a /settings-supplied value, so a tampered /settings cannot choose where discovery — and the credential POSTs it resolves — are aimed. Without a pin, discovery is refused rather than guessed.
  • Co-location pin. validateEndpointOrigins, enforced on every construction path (discovery and the explicit builder()), requires the token and device-authorization endpoints to share one origin (RFC 8628 co-locates them on a single authorization server), so a tampered /settings or 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 /settings response 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 %2f or %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 /settings from steering credentials to a sibling tenant. The issuer is supplied out of band and cannot be forged.
  • IdP-discovered endpoints are trusted where the issuer hosts them. An endpoint read from the issuer's own .well-known is 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.
  • Plaintext-channel pin. A /settings response fetched over plaintext http to a non-loopback host (only reachable with allowInsecureTransport) is MITM-able, so its advertised endpoints are not trusted to route credentials without an issuer pin.

Without a pin, the behaviour against an https server that advertises its endpoints is unchanged: that server is trusted, as before.

Security

  • https is required by default for both the QuestDB server and the IdP endpoints; http is rejected unless the caller opts in with allowInsecureTransport(true). That opt-in relaxes only the QuestDB /settings link — the IdP device-authorization and token endpoints always require https (loopback excepted), so the device code and refresh token never cross the network in cleartext (matching the Python client).
  • Tokens never leak into logs or exceptions. Only the HTTP status of a token/device response is captured; the body — which carries access, id and refresh tokens — is never retained or surfaced in a message. The captured status is validated to be exactly three digits, so a malformed or hostile status line cannot splice ANSI/control bytes into a later [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.
  • Untrusted IdP text is sanitized before it is shown in a prompt or an exception message: per-code-point stripping of control characters, ANSI escapes, CR/LF, and bidirectional / zero-width / Unicode-format characters (including supplementary-plane "tag" characters that arrive as surrogate pairs, and unpaired surrogates), so an attacker-influenced field cannot reorder, hide, or forge what a human reads — e.g. a right-to-left override that makes the displayed verification URL differ from the one the browser opens. A user code or verification URL that is non-empty on the wire but sanitizes to nothing is rejected (or, for verification_uri_complete, treated as absent) rather than shown as a blank line or handed to the browser launcher.
  • The token itself is validated before use. The token QuestDB will actually receive — the id token when the server encodes groups in the token, the access token otherwise — is rejected if it carries a control or non-ASCII character (outside 0x200x7e) before it is cached, placed in the Authorization: Bearer header, 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. (The JsonLexer change below decodes JSON escapes, which is what turns a \r/\n in a token into a real byte rather than two literal characters.)
  • URLs are validated up front. Endpoint.parse rejects 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.
  • Bounded against a hostile or stalled server. Response reads are capped by a 4 MiB byte limit and a wall-clock deadline that bounds the whole read — covering both a chunked response whose chunk-size line is dribbled a byte at a time and a Content-Length body 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). A 429 with no OAuth error is treated as a transient back-off (a 429 that also carries a terminal error such as access_denied still aborts on the error); a transient transport failure or 5xx during 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 terminal 4xx aborts 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 TokenStore persists 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 explicit signIn().

  • TokenStore SPI (io.questdb.client.cutlass.auth) — load/save/clear keyed by a non-secret TokenStoreKey (endpoints, client id, scope, audience, groups-in-token mode), plus an optional inLock hook for cross-process coordination. Wire it in with builder().tokenStore(...) or DiscoveryOptions.tokenStore(...). Persistence is best-effort: a store failure warns to System.err and the in-memory token is used regardless.
  • FileTokenStore (the default) — one plaintext JSON file per identity under ${user.home}/.questdb/oidc-tokens/ (override with questdb.client.oidc.token.store.dir), the refresh token protected at rest by file permissions (0600 file, 0700 directory on POSIX) rather than encryption — the same approach gcloud, aws and gh take. The file name is a SHA-256 of the identity, so it leaks neither endpoint nor client id and several identities coexist. FileTokenStore.atDefaultLocation() / FileTokenStore.at(dir).
  • Treated as untrusted on load. A persisted file is attacker-writable, so on load it is size-bounded, parsed defensively (a corrupt/oversized/garbage file is ignored, not fatal), its fingerprint re-checked against the live config (a token minted for one identity is never served for another), and the served token re-validated for control/non-ASCII characters before it can reach a header — the same CR/LF / non-ASCII rejection the device flow applies to IdP responses. A tampered far-future expiry is clamped, not trusted. A bad file degrades to a refresh or an interactive sign-in.
  • Integrity and cross-process coordination. Each update is written to a temp file then atomically renamed, so a concurrent reader never sees a half-written credential. When the IdP rotates the refresh token on each refresh, the read-refresh-write is serialized across processes with an O_CREAT|O_EXCL lock file (not an OS advisory lock, which Java FileLock and Python flock cannot share); a process that cannot acquire the lock degrades to a lock-free refresh rather than stall.
  • The on-disk format is a frozen cross-language contract (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

  • JsonLexer now 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 decoded message/code/line/errorId fields.
  • Response.recv(int timeout) (both the Content-Length and chunked implementations) 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, or Content-Length bytes behind stalled TLS records — cannot keep a single read running past the caller's deadline. A non-positive timeout keeps the legacy unbounded behaviour. The existing ILP flush reads go through the no-arg recv(), which passes the configured client timeout (positive by default), so they now bound the whole body read to that timeout instead of re-arming it per socket read: a response that completes within the timeout is unaffected, while one that legitimately dribbles a single fragment for longer than the timeout — previously tolerated as long as each socket read made progress — now aborts.
  • AbstractLineHttpSender plumbs 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 through putAsPrintable — 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 a LineSenderException (or spliced into a log line or terminal).
  • QwpWebSocketSender sources 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-known wherever the provider hosts it, so an off-origin provider (e.g. Google) signs in normally through a pinned issuer; only an endpoint the /settings response itself advertised is held to the issuer's origin and path. The explicit builder().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). A getToken() 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.x address that the loopback classifier deliberately rejects as non-loopback. That trick relies on the OS resolver expanding the short form (BSD inet_aton, on Linux/macOS), which Windows getaddrinfo does 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 TokenStore backed by an OS keychain or a secrets manager instead of FileTokenStore. 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 warns once to System.err.
  • A store-coordinated getToken() may briefly wait to acquire the cross-process lock before a silent refresh (a few seconds at most for FileTokenStore — 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) whole-read bound (see Supporting changes) also tightens existing, non-OIDC ILP flushes. The no-arg recv() now bounds the whole flush-response body read to the configured client timeout instead of re-arming it per socket read, so a response that legitimately dribbles a single fragment for longer than that timeout — previously tolerated as long as each socket read made progress — now aborts. A healthy response is unaffected: the abort surfaces as a retryable HttpClientException that flush0 catches and retries like any transport failure, so the only observable effect is that a pathologically slow single fragment is retried rather than tolerated indefinitely, marginally widening the pre-existing ILP-over-HTTP at-least-once window.

Tests & docs

OidcDeviceAuthTest (~107 cases) + MockOidcServer, BrowserLauncherTest, LineHttpSenderTokenProviderTest, WebSocketTokenProviderTest + TestWebSocketServer, SenderBuilderErrorApiTest, JsonLexerTest, LineHttpSenderErrorResponseTest, DisplaySafeTest, ChunkedResponseTest/ResponseTest, QwpQueryClientTokenProviderTest, FileTokenStoreTest, OidcDeviceAuthPersistenceTest; runnable OidcDeviceFlowExample / OIDCAuthExample; and README "OIDC Sign-In (Device Flow)" and "Persisting the Token Across Restarts" sections. Coverage includes:

  • .well-known discovery via a pinned issuer; a discovery document that omits the device-authorization endpoint
  • the co-location check rejecting split-origin endpoints; the builder().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-known accepted even when off the issuer origin (the Google case)
  • issuer path scoping: endpoints under the issuer path accepted; a sibling realm, percent-encoded traversal, an encoded path separator (%2f), and a percent-encoded backslash (%5c) rejected
  • the plaintext-channel pin requiring an issuer pin for advertised endpoints over http (firing path skipped on Windows)
  • the audience parameter discovered from /settings and sent on the device and refresh requests
  • token-provider support over HTTP and WebSocket (initial upgrade and re-queried per reconnect; static token / username-password still work over WebSocket); rejected for TCP/UDP; mutual exclusion with other auth; null/empty provider token rejected; deferred build-time pull; no sender corruption when the provider throws after a flush; the QWP egress query client's token provider — header synthesis, per-resolve re-query, token validation, and mutual exclusion
  • a token with a control or non-ASCII character rejected rather than sent (the served kind validated); tokens never echoed in messages
  • bounded reads: a stalled body and an oversized body aborting on the deadline / 4 MiB cap; a chunked body read aborting when the chunk-size line is dribbled; a bounded-read abort dropping the dirty poll connection so the next poll reconnects and signs in
  • the poll model: a 429 and a transient 5xx/transport failure keep polling to the deadline; a terminal 4xx and an OAuth error fail fast (including a 429 that also carries a terminal error); slow_down growth and the 60 s interval clamp; device-code-lifetime and clock-skew clamps
  • Endpoint.parse rejecting a malformed url: userinfo (user@host), a bracketed IPv6 literal, an out-of-range port, and control/whitespace/display-unsafe characters
  • a malformed HTTP status code rejected on both the device-authorization and the token-poll path: a non-numeric status, and a short all-digit status (2, 5) that must not be read as a 2xx/5xx class
  • display sanitizing: bidi/zero-width, lone-surrogate and supplementary-plane format characters stripped from the challenge and OAuth error; a server's JSON error body, its HTTP status field, and the protocol-version probe body with such characters escaped, not rendered raw; a user code or verification URL that sanitizes to empty rejected, and a verification_uri_complete that sanitizes to empty treated as absent
  • JsonLexer escape decoding, including a \uXXXX escape split across two parse fragments, and the lenient/exotic escape arms
  • getToken() 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
  • token persistence: round-trip save/load with 0600/0700 permissions 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 contract

Review 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:

  • Blank served token: adopt()/storeTokens() accepted a whitespace-only served token (it passed isEmpty()/hasOnlyTokenChars() vacuously), so signIn() reported success and getToken() served a blank Bearer header the server only answers with 401, never falling back. Now rejected via Chars.isBlank; a blank served kind is folded to absent so selectToken() surfaces the actionable error.
  • getToken() lock contention: the unconditional tryLock() failed fast on any lock hold, so concurrent callers sharing one OidcDeviceAuth threw on every token refresh. It now waits briefly behind a peer's silent refresh (bounded by httpTimeoutMillis) and fails fast only behind an interactive sign-in.
  • SYNC initial connect: a token-provider failure was treated as a transport outage and retried for the whole reconnect budget (5 min default) then wrapped. It now fails fast with the provider's own exception, matching the OFF-mode and background-reconnect paths.

Hardening and coverage:

  • Discovery parsers reject array-wrapped JSON, so {"config":[{...}]} can no longer surface fields at the trusted config depth.
  • The cross-process lock file is created with its owner stamp in one atomic exclusive open, removing the create-then-stamp gap a GC pause could straddle (design doc updated; the Python client must mirror it).
  • The ILP token-provider request is built once per flush rather than twice. (validateToken still 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.)
  • Added regression tests for previously untested load-bearing guards: the parseHex4 non-ASCII guard, the raw .. issuer-path reject, the FileTokenStore size caps, the store-and-forward credential-timer reset, and the ILP flush whole-read timeout bound.
  • Docs: HttpTokenProvider.getToken() now discloses the OS-bounded connect stall; TokenStore.inLock documents its no-reentrancy contract; FileTokenStore states the concurrent-refresh residual (token-family revocation on a reuse-detecting IdP) instead of understating it.

Tandem

OSS: questdb/questdb#7331
Ent: https://github.com/questdb/questdb-enterprise/pull/1090

@glasstiger glasstiger changed the title feat(core): OIDC device flow feat(core): OIDC sign-in via device flow (RFC 8628) Jun 17, 2026
glasstiger and others added 19 commits June 17, 2026 19:31
Two fixes for the OIDC device flow in the Java client.

M2 - Bidi / zero-width Unicode bypassed the display sanitizer.
sanitizeForDisplay and OidcAuthException.putSanitized filtered only on
Character.isISOControl, which covers C0/C1 and DEL but not the
bidirectional overrides (U+202A-202E), isolates (U+2066-2069), marks
(U+200E/200F), zero-width characters or the BOM (U+FEFF). Those fields
- user_code, verification_uri(_complete), error and error_description -
all come from the IdP/settings boundary and reach System.out and the
exception messages, so a hostile or MITM'd IdP could embed a
right-to-left override and spoof the verification URL a human reads and
then opens. The JSON lexer's \uXXXX decoding widens the vector, since an
escaped override decodes to the real character before display.

Both sanitizers now share OidcAuthException.isUnsafeForDisplay, which
also strips the Unicode format category (Cf) plus the explicit bidi/BOM
set. The predicate uses hex int literals rather than char escapes,
keeping the source strictly ASCII so the file carries none of the
characters it guards against.

M3 - httpTokenProvider forced a successful sign-in before build().
createLineSender eagerly rebuilt the pending request when a provider was
set, calling getToken() at build time. With the documented
.httpTokenProvider(auth::getTokenSilently), that threw unless the caller
had already signed in, so the natural "construct the sender, sign in,
then send" ordering was impossible.

The first token pull is now deferred off the build path to the first row
(table()). The provider is wired at build but not queried; the initial
request is stamped with a token when the first row starts, and the
pending flag is cleared only after the pull succeeds, so a
not-yet-signed-in provider that throws leaves the stamp pending for a
retry. The Sender.httpTokenProvider Javadoc now states the provider is
not called at build time.

Tests: new bidi/zero-width cases for the challenge fields and the oauth
error message (fed as JSON \uXXXX escapes so they exercise the
decode-then-display path), and a new LineHttpSenderTokenProviderTest
covering the deferred pull and a lazily signing-in provider. Each test
was confirmed to fail without its fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three robustness fixes in the OIDC device-flow parser.

m3 - A JSON null arrives from the lexer as the literal "null". The
token and device parsers used putValue, which stored it verbatim, so
"access_token": null became the 4-char token "null" and "error": null
was read as an OAuth error code "null". Merged putValue with
SettingsDiscoveryParser's null-guarding putNonNull into one shared
helper used by all three parsers, so a JSON null is treated as absent
everywhere.

m4 - Endpoint.parse did not range-check the port, so host:0, host:-1
and host:99999 parsed and flowed to the transport. Added a 1..65535
guard that rejects them with a clear message.

m5 - The token-response expires_in was not clamped, unlike the
device-auth value, so a TTL near Integer.MAX_VALUE cached the token for
~68 years. storeTokens now applies the same boundedSeconds clamp (the
default for a non-positive value, capped at MAX_EXPIRES_IN_SECONDS). The
server still enforces the real expiry; this only bounds how long the
client trusts its cached copy.

Tests: null access_token and null error are rejected/ignored,
out-of-range ports are rejected at build, and a clamped token expiry
forces a fresh sign-in (observed via a clock-skew margin set above the
clamp). Each test was confirmed to fail without its fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The post-flush reset() eagerly rebuilt the next request and pulled the
provider token via httpTokenProvider.getToken() after the current batch
had already been sent and accepted. If that pull threw (e.g.
OidcDeviceAuth::getTokenSilently when a silent refresh fails) it turned
an already-successful flush into a thrown exception and left the shared
Request half-built (contentStart == -1, no withContent()), so the next
row's data went into the header region - a malformed request, lost rows
and a permanently corrupted sender.

Route every request's token pull through the same deferred, retriable
path the initial request already used: newRequest() no longer pulls the
provider token (it marks the request token-pending and builds a valid
token-less request), and stampTokenIfPending() pulls it lazily when the
first row of a request starts. A failed pull leaves the flag set and the
sender untouched, so the next row re-runs the stamp and fully rebuilds
the request. Per-request token rotation is unchanged.

Rename isInitialTokenPending/stampInitialTokenIfPending to
isTokenPending/stampTokenIfPending since the deferral now covers every
request, and stamp the token in putRawMessage() too. Add a regression
test that fails at the first, successful flush without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getToken() and getTokenSilently() were both synchronized on the instance
monitor, and getToken() holds it for the entire interactive device flow -
up to the device-code lifetime, clamped to one hour. A long-lived Sender
wired with httpTokenProvider(auth::getTokenSilently) therefore stalled on
the flush path for up to an hour whenever another thread ran an
interactive sign-in (e.g. a re-auth after the refresh token died). The
javadoc claimed the opposite ("safe on a request/flush path").

Replace the synchronized methods with a ReentrantLock. getToken() and
clearCache() still acquire it blocking, but getTokenSilently() now uses
tryLock() and fails fast with an OidcAuthException instead of waiting:
while a sign-in is in progress there is no token to serve anyway, so the
caller gets a prompt, retriable exception rather than a wedged flush. The
interactive flow still holds the lock for its whole duration and close()
still sets the volatile cancellation flag before acquiring the lock, so
the no-use-after-free guarantee is unchanged.

Correct the class and getTokenSilently() javadocs, and add a regression
test that fails (getTokenSilently blocks ~10s behind an in-flight
sign-in) without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
@glasstiger glasstiger added enhancement New feature or request security labels Jun 19, 2026
glasstiger and others added 7 commits June 19, 2026 15:42
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>
glasstiger and others added 24 commits July 14, 2026 17:17
Three confirmed defects in the OIDC device-flow token provider,
each with a regression test proven to fail without the fix:

- Blank served token: adopt() and storeTokens() accepted a
  whitespace-only served token (isEmpty/hasOnlyTokenChars pass it
  vacuously), so signIn() reported success and getToken() served a
  "Bearer  " header the server only answers with 401 - never
  falling back to a refresh or sign-in. adopt() now rejects it via
  Chars.isBlank; storeTokens() folds a blank served kind to absent
  so selectToken() surfaces the actionable "no access_token".

- getToken() lock contention: the unconditional tryLock() failed
  fast on ANY lock hold, so concurrent callers sharing one
  OidcDeviceAuth (the documented shared-provider pattern) threw on
  every token refresh. getToken() now waits briefly behind a peer's
  quick silent refresh - bounded by httpTimeoutMillis - and fails
  fast only behind an interactive sign-in, tracked by a new flag.

- SYNC initial connect: connectWithRetry() treated a token-provider
  failure as a transport outage and retried it for the whole
  reconnect budget (5 min default), then wrapped it. It now fails
  fast with the provider's own exception, matching the OFF-mode and
  background-reconnect paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add regression tests for four load-bearing guards that had no
coverage; each is proven to fail without the guard:

- JsonLexer.parseHex4 non-ASCII window guard: a backslash-u escape
  whose four-hex window holds a non-ASCII char (valid UTF-8) is
  kept verbatim; without the c<128 guard it throws an AIOOBE on the
  int[128] hex table, which escapes as an unchecked exception past
  the OIDC callers (they catch only JsonException).

- isEndpointUnderIssuerPath raw dot-segment reject: a bare
  (unencoded) ".." or "." segment - which every other traversal
  test encodes and an earlier gate catches - must be rejected so a
  tampered /settings cannot steer credentials to a sibling realm.

- FileTokenStore size caps: the two existing tests passed
  regardless of the cap (an all-spaces file failed the version
  check anyway; a stale lock stole on mtime regardless of size).
  Reworked to isolate each cap - a valid oversized token file, and
  a lock in the window where only an unreadable (capped) read is
  stolen.

- CursorWebSocketSendLoop credential-timer reset: a credential
  blip interleaved with transient role rejects must not accumulate
  toward the terminal budget; without the reset the sender
  terminates and buffered store-and-forward data is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Discovery parsers now reject array-wrapped JSON: an object nested
  in an array (e.g. {"config":[{...}]}) can no longer surface its
  fields at the trusted config / top-level depth. All four discovery
  parsers track array depth and read names/values only at array
  depth 0, mirroring FileTokenStore's parser. Adds a regression test.

- close() frees the native JsonLexer before the HttpClients, so a
  throw from a client close cannot strand the native buffer.

- BrowserLauncher: extract the kill-switch into a testable
  isBrowserOpenEnabled() predicate; the test now asserts the gate
  actually flips (it previously had no assertion on the kill-switch).

- SenderBuilderErrorApiTest: replace vacuous assertNotNull on enum
  constants with valueOf() checks that fail at runtime if a constant
  is renamed or removed.

- Docs: HttpTokenProvider.getToken() states the silent-refresh
  connect stall (OS-bounded, ~2 min worst case); TokenStore.inLock
  documents the no-reentrancy / no-blocking contract; FileTokenStore
  honestly states the concurrent-refresh residual (token-family
  revocation on a reuse-detecting IdP, headless hard-failure) rather
  than "just a re-prompt".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two moderate perf findings on the ILP-over-HTTP token-provider
flush path:

- Build the request once, not twice. The deferred-token design
  (getToken() is pulled at the first row, not on the flush-
  completion path, for throw-safety and build-before-signIn)
  previously built the request line and headers in reset(), then
  discarded and rebuilt them when the first row stamped the token.
  Now newRequest() leaves the request at the header stage (no
  withContent yet) and stampTokenIfPending() appends the auth
  header + withContent() on the same request - no second
  client.newRequest(). Only the token-provider path changes;
  bufferView() still reads empty before the first row, and the
  throwing pull runs before the request is mutated so a failed
  getToken() leaves it retriable, not corrupted.

- Skip re-validating an unchanged token. validateToken() scans the
  whole (multi-KB) token; it is now skipped when the provider
  returns the same instance already validated, and always run for a
  null or changed token. Adds a regression test that a token
  changed to a bad one is re-validated and rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two moderate findings previously left as documented residuals,
now fixed since the design doc (new in this PR) can be updated in
tandem with the Python client:

- Eliminate the lock-file create->stamp gap. acquireLock created an
  empty <hash>.lock then stamped it in a second call, so a GC/safepoint
  pause straddling the two could make a freshly-created lock look
  empty-and-stale to a peer and be false-stolen, risking a concurrent
  refresh. createLockFile now creates the lock AND writes the owner
  nonce in one atomic exclusive open (CREATE_NEW), so a live lock
  always carries a stamp - there is no Java-level gap. The empty-lock
  grace remains for the rare crash-mid-write. writeLockHolder and its
  ownership-verification dance are gone. The design doc is updated to
  the atomic create-with-stamp protocol the Python client must mirror.

- Cover the ILP flush response whole-read timeout bound end to end.
  The no-arg recv() the flush uses bounds the WHOLE body read to the
  configured timeout, not each socket read; that was unit-tested on
  the Response classes but not driven from a real flush over a real
  socket. A new MockOidcServer.dribble() sends chunked headers then
  the chunk-size line one byte at a time (never completing), and the
  test asserts a flush aborts on the ~1s request timeout - proven to
  fail (~11s) with the whole-read bound removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The httpTokenProvider work introduced two defects.

cancelRow() could segfault the JVM. With a provider configured,
newRequest() defers the token and leaves the request at the header
stage, so contentStart stays at its -1 sentinel and no row bytes are
buffered. cancelRow() then ran trimContentToLen(0), which set the
write pointer to -1, and the next buffer write faulted in
Unsafe.putByte. cancelRow() now returns early while the token is
pending (nothing is buffered yet), and trimContentToLen refuses the
-1 sentinel as a defensive guard.

The store-and-forward background drainer could terminate a producer on
a transient credential outage. It bounded a token-provider failure by
reconnect_max_duration_millis and then latched a SECURITY_ERROR
terminal, dropping a producer that store-and-forward had promised to
keep alive. A failing provider (IdP unreachable, a silent refresh
failing, an interactive sign-in in progress) is a transient outage
like any other, so the running drainer now retries it indefinitely
with capped backoff, per Invariant B. The foreground/SYNC initial
connect still fails fast, because a connectivity error is only the
caller's problem during initialization. This restores the file's own
field comment, which already declared reconnect_max_duration_millis
"NOT consulted by the background loop".

The crash gets a regression test proven to segfault without the guard.
The drainer tests now assert the sender survives a persistent provider
outage and recovers, replacing a catch-all that asserted nothing about
the terminal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three defects in the OIDC token-provider work.

The ILP HTTP sender skipped token validation whenever the provider
returned the same CharSequence instance it had validated before, on
the theory that an instance never changes content. HttpTokenProvider
makes no such promise: a provider that reuses one buffer (the idiomatic
zero-alloc style) and mutates it in place between flushes had its
mutated token spliced verbatim into the Authorization header, which
request.authToken writes with no CR/LF filtering - a header-injection
bypass of the very check validateToken exists to enforce. The sender
now validates every pulled token; the scan is O(token length) and is
dwarfed by the flush's network round-trip, matching what the WebSocket
auth path already does on every pull.

getToken() acquired its lock with the interruptible timed tryLock even
on the uncontended fast path. That overload throws InterruptedException
the moment the calling thread merely carries a set interrupt flag -
even on a free lock - and the handler re-arms the flag, so every later
getToken() on that thread failed with a valid token sitting in the
cache. ILP producers commonly run on pooled or managed threads where
interrupt is the standard cancellation signal. An untimed tryLock now
handles the uncontended case; the timed poll remains only for genuine
contention behind a peer's silent refresh.

The issuer-path pin rejected a "." or ".." path segment but not "..;":
a server or proxy that strips RFC 3986 matrix parameters resolves
/realms/acme/..;/evil to /realms/evil, a different realm on the same
host, redirecting the device code and refresh token to a sibling
tenant. The dot-segment check now strips a ";suffix" from each decoded
segment before comparing.

Each fix gets a regression test proven to fail when the fix is
reverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A batch of moderate review findings across the OIDC device flow, the
token store, the ILP-over-HTTP flush path, and the QWP senders, plus the
test-quality issues they surfaced.

OidcDeviceAuth:
- getToken()'s peer-wait budget was one httpTimeoutMillis, but a lock
  holder doing a silent refresh can legitimately hold for up to
  LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE times that, so a concurrent caller
  threw on a refresh that was going to succeed. The peer now waits the
  holder's own worst-case hold.
- getToken() no longer forecloses a silent refresh when the served-kind
  token is null but a refresh token exists (the partial grant a
  groups-in-token sign-in with no id_token leaves behind): it attempts
  the refresh instead of throwing "no token has been obtained yet".
- close()'s javadoc claimed it returns after at most one HTTP request
  timeout; corrected to describe the in-flight refresh's real worst
  case, an OS-bounded connect stall.

FileTokenStore:
- inLock() now serializes same-identity critical sections within one JVM
  with a process-wide lock, so two OidcDeviceAuth instances for one
  identity cannot both run the read-refresh-write when the cross-process
  file lock degrades and double-POST the same rotating refresh token,
  which a reuse-detecting IdP revokes the whole family for.
- warnNoPosixPermsOnce/warnPersistence log via SLF4J instead of
  System.err, so a host application can filter and redirect them.

ILP over HTTP:
- The response-body reads inherited the raw request_timeout instead of
  the per-flush budget (base plus the throughput extension), so a
  tuned-low request_timeout with request_min_throughput could abort a
  large, still-progressing chunked error body and turn it into a retry
  of a non-retryable status. The body reads now use the per-flush budget.

QWP:
- A rejected table or column name was spliced raw into the error message
  (QwpWebSocketSender, QwpUdpSender, QwpTableBuffer); it now routes
  through putAsPrintable, matching the ILP name/error render, so a
  BOM/bidi/control char in a hostile name cannot reorder or forge the
  displayed text.

Tests:
- A swallowed Assert.fail on a mock-server thread, a mutual-exclusion
  test that passed if a contender died silently (now with a barrier and
  a run counter), a vacuous enum valueOf cross-check, and a sleep-gated
  negative assertion that could pass before its waiter thread started are
  all fixed to assert on the main thread and to prove the thread is
  genuinely blocked.
- Added coverage for a unicode escape at the end of a JSON value, the
  TokenStore.inLock default, putRawMessage's token stamp, and the
  null-served-kind refresh; each new production fix's test is proven to
  fail when the fix is reverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tryRefresh's hasRequiredToken gated the served kind on length() > 0
while storeTokens folds a whitespace-only token to null via
Chars.isBlank. A non-conformant 2xx refresh with a blank access/id
token therefore reported success, cached a token storeTokens then
nulled, and made signIn() throw "no access_token" instead of falling
back to the interactive device flow. Gate hasRequiredToken on
!Chars.isBlank so the refresh gate and the cache agree, and add
testBlankTokenFromRefreshFallsBackToInteractiveFlow, which fails
without the change with the exact "no access_token" error.

Correct three stale docs left by the SF-drainer-terminal fix:
- QwpCredentialUnavailableException's javadoc described a
  reconnectMaxDurationMillis-bounded terminate that no path
  implements; the running store-and-forward drainer retries a
  credential-unavailable failure indefinitely under Invariant B, and
  only the foreground/SYNC initial connect fails fast.
- Sender.httpTokenProvider's javadoc claimed a WebSocket sender
  terminates on a sustained token outage; a running SF-backed sender
  retries token-pull failures indefinitely and recovers, as
  testPersistentlyThrowingProviderOnReconnect... already proves.
- FileTokenStore's createLockFile/acquireLock/stealIfStale comments
  claimed a single atomic create+stamp with no gap; writeNewFile
  opens then writes separately, so a GC pause can land in the empty
  window, which EMPTY_LOCK_STEAL_GRACE_MILLIS covers.

Add testProviderTokenReResolvedOnFailoverReconnect, which drives a
real QwpQueryClient failover reconnect and asserts reconnectViaTracker
re-resolves the token provider so a rotated token reaches the
reconnect upgrade, closing the one untested cross-context provider
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review findings where documentation and one test described
behavior the code does not implement. Comments/test-name only; no
runtime behavior changes.

- QwpWebSocketSender: three comments claimed a token-provider failure is
  retried within the reconnect budget and then terminates the sender. The
  actual behavior is the opposite - the foreground/SYNC connect fails fast
  with the provider's exception, while the running background drainer
  retries indefinitely (never budget-bounded, never terminal) per
  store-and-forward Invariant B. Rewrite all three to match.

- OidcDeviceAuth.getToken javadoc said the store per-identity lock wait is
  "a few seconds at most, then proceeds without the lock." That bound is
  the cross-process file lock; the in-process lock guarding two same-JVM
  instances of one identity is not time-bounded and can wait out the
  peer's whole refresh. Clarify the distinction.

- AbstractLineHttpSender: reword the drain comment - the per-flush budget
  bounds each recv() read, not the whole body cumulatively (fine here
  because the ILP server is trusted).

- FileTokenStoreTest: rename
  testConcurrentStealContentionTwoWayPreservesMutualExclusion to
  testSameProcessContendersSerializeAndBothStealStaleLock. Its
  overlaps==0/maxInside==1 assertions are guaranteed by the in-process
  PROCESS_LOCKS lock, not the file-lock capture-verify the old name
  claimed, so the cross-process exclusion is masked in a single JVM.
  Re-comment to document that the cross-process property is
  inspection-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LineHttpSenderV1.at() and LineHttpSenderV2.at() wrote the leading
space and the timestamp before atNow() validated the row state, so an
at() with no preceding table() emitted those bytes and only then threw.

With an httpTokenProvider configured this corrupts the request.
newRequest() leaves the request at the header stage - withContent() is
deferred until the first row stamps the Authorization header - so the
stray bytes land in the HTTP header block, on a line of their own. The
next row's "Authorization: Bearer ..." is then appended to that line,
which makes it an obs-fold continuation of User-Agent (RFC 7230) rather
than a header of its own. The flush ships with no credential at all,
the server answers 401, and close() drops the buffered rows because
flush0() returns early on lastFlushFailed. cancelRow() cannot undo it:
trimContentToLen only rewinds within the content section, and it
early-returns while the token is pending anyway. Without a provider the
same misuse only wrote into the request body, where cancelRow() cleaned
it up, so the deferred-token design is what turned a recoverable API
misuse into silent credential and data loss.

AbstractLineHttpSender now exposes validateRowStarted(), which both
at() overloads call before their first write. atNow() calls it too and
then writes the terminator unconditionally - equivalent to the old
switch, since RequestState has exactly four constants and the guard
rejects two of them. LineHttpSenderV3 inherits V2's at().

A sweep of every public row-building method - 21 methods over two
protocol versions - confirms at() was the only write-before-validate
path: symbol, the column setters, the array columns, atNow and
cancelRow all reject before writing.

The regression test drives both at() overloads over V1 and V2 and
asserts on the header the mock server actually parsed off the wire,
rather than on an exception message. It fails without the production
change with "expected:<Bearer TOKEN> but was:<null>" - null being the
symptom that no credential reached the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The background reconnect loop retries a
QwpCredentialUnavailableException indefinitely under Invariant B, which
is correct - a token provider hands over a credential again once the IdP
is reachable or the user finishes signing in, and the un-acked rows stay
safe in store-and-forward. But the credential arm was the only
endpoint-policy failure that did not also dispatch a SenderError, so the
retry was programmatically invisible.

That matters because a credential outage is often NOT self-healing: a
revoked refresh token, or an IdP that is permanently unreachable from
this host. Meanwhile flush() keeps returning success while SF absorbs
the rows, so the only signal is a throttled slf4j WARN - and this
library ships embedded, frequently with no binding configured. It then
resurfaces much later as ring backpressure, which points the operator at
disk sizing instead of at their credentials. The javadoc on
dispatchRetriedEndpointPolicyFailure already describes exactly this
hazard, and the auth/upgrade and durable-ack arms both dispatch for it.

The arm now dispatches a SECURITY_ERROR carrying the provider's own
message under a "credential-unavailable: " prefix, matching how the
sibling arms label theirs. The policy stays RETRIABLE, never TERMINAL:
the handler learns the wire is down while the producer stays alive and
no data is at risk.

The regression test drives a persistent provider outage on a running
sender and asserts the handler observes the category, the RETRIABLE
policy and the provider's message, that no terminal is ever latched, and
that the sender still drains once the provider recovers. Without the
production change it fails on the dispatch wait: "waitFor timed out
after 15000ms".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reconnect walk publishes the WebSocketClient it is about to block on
so close() can break it, but the credential pull that now precedes the
walk had no such handle. A token provider is caller code owning no
socket, so closeTraffic() cannot reach it, and it can block far longer
than close()'s 30s budget: OidcDeviceAuth.getToken() waits up to
4 x httpTimeoutMillis (120s by default) behind a peer's silent refresh.

The pre-check at the top of buildAndConnect does not cover this. It
only rejects a close that already happened, and during an IdP outage the
drainer sits inside a pull for most of every retry cycle, so close()
lands there routinely rather than in a narrow check-then-act race. It
then burned the whole budget and threw "cursor I/O thread did not stop",
delegating teardown, on what should have been a clean shutdown.

ConnectCancellation now carries the thread that is inside a pull, and
cancel() interrupts it. That is the only lever which reaches a
Java-level wait: OidcDeviceAuth converts the interrupt into a provider
failure, which the reconnect loop already handles as a transient outage
before observing the abort and exiting. buildAndConnect publishes the
thread before the pull, re-checks cancellation, and clears the marker in
a finally, so a later cancel() cannot interrupt the walk at an arbitrary
point. The marker is only ever set while a pull is in flight, so a
sender with no token provider sees no change at all.

This does NOT cover a provider stalled in an OS-level TCP connect, which
ignores interrupts; close() still loud-fails on its budget there, as
before. The comment at the cancel site says so rather than implying the
window is closed.

The regression test parks the drainer in a pull that only an interrupt
can release, then times close(). With the fix it returns promptly; with
the interrupt lever removed it reproduces the defect exactly, failing
after 30s with "cursor I/O thread did not stop: close() timed out after
30000ms awaiting shutdown".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
design/oidc-token-persistence.md is the spec the Python client mirrors,
but it had drifted from the code it describes. The schema example wrote
both endpoint fields without a port, while this client writes and
compares the canon() rendering, where the port is always explicit, with
an exact string compare. A peer client following the example produces a
file this client silently ignores: load() returns null, the process
re-prompts and re-persists in its own encoding, and the two never
converge. Every existing test round-trips through this client's own
writer, so nothing caught it.

The lock section claimed the owner stamp is written "in the SAME atomic
open" and that there is "no create->stamp gap" for a pause to straddle.
That is not what the code does, and commit 2acba17 already corrected
the source comments to say so: the exclusive create and the stamp write
are two operations on one handle, and EMPTY_LOCK_STEAL_GRACE_MILLIS is
what stops a peer stealing a lock that is mid-stamp. A client that
believed the doc could shorten its grace and start stealing live locks.

Corrected, and pinned where the doc was vague: the empty-lock grace is
5 seconds, not "a few"; a lock over 4 KiB reads as unstamped; a temp
sweep must skip names containing ".lock.", which are in-flight steal
captures. The API section gained the inLock/CriticalSection hook it was
missing, lost the equals/hashCode that TokenStoreKey does not have, and
now describes clear() running under the cross-process lock.

The file NAME hash was already pinned by a golden-value test; the file
BODY was not. testFrozenSchemaEndpointsCarryAnExplicitPort closes that:
it loads the documented encoding, then loads the same document with the
default ports omitted and asserts it is rejected. The two halves differ
only in the port, so the rejection can only be the endpoint fingerprint.

Left alone: the doc still records System.err as the channel for the
persistence-failure warning while the code uses SLF4J. Resolving that
means deciding which one is wrong, which is a separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AbstractChunkedResponse.recv(int) promises to bound the whole call, and
OidcDeviceAuth.parseBody and discardBody rely on that promise against an
identity provider they treat as untrusted. The bound did not hold.

Numbers.parseHexLong accumulates val << 4 with no overflow check, so a
chunk-size line of 16 or more hex digits wraps negative - 8000000000000000
is exactly Long.MIN_VALUE. A negative size matches neither the "size > 0"
data branch nor the "size == 0" terminator, so STATE_CHUNK_DATA breaks
straight back to the top of the loop. The preceding chunk left
receive == false with bytes still buffered, so the read gate is skipped
too - and the deadline check lived INSIDE that gate, so the loop never
consulted it. The result is an unbounded CPU-burning spin on a size line
the server chooses. The spinning thread holds the OidcDeviceAuth instance
lock, so close() never returns either.

recv(int) now rejects a negative size as a malformed chunk size, which
closes the reachable path. The deadline check also moves above the read
gate so the bound holds on every pass rather than only on the passes that
read; that half is defence in depth for any future state-machine path
that does not read, and has no independent test of its own.

MockOidcServer.dribble() steps around this deliberately - it dribbles
leading-zero hex digits so "the parsed size stays 0 (so nothing
overflows)" - which is why the existing bounded-read tests never met it.

The regression test feeds one well-formed chunk followed by the
overflowing size line, with defaultTimeout = -1 so no deadline can rescue
the loop and only rejecting the size can end the call. Without the
production change it spins to the 30s @test timeout and fails with
TestTimedOutException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FileTokenStore does all its I/O through FileChannel, an
InterruptibleChannel: a thread that merely CARRIES a set interrupt flag
makes the first read or write throw ClosedByInterruptException, the
channel closes mid-operation, and the flag survives. Two callers arrive
that way routinely. An ILP producer on a pooled or managed thread uses
interrupt as its cancellation signal - acquireForGetToken's own comment
calls that the common case, and it re-arms the flag on the contended path
with nothing ever clearing it. The sender's I/O thread is interrupted
deliberately by close(), to break a drainer stuck in a credential pull.

Two consequences followed. inLock's releaseLock read threw and was
swallowed, so the <hash>.lock file survived its whole staleness window -
10 minutes by default - while every peer degraded to an unserialized
refresh, which is the rotating-refresh-token race the lock exists to
prevent. And maybeLoadFromStore latched storeLoadAttempted BEFORE the
read, so one interrupted load disabled persistence for the whole life of
that OidcDeviceAuth: a process owning a good refresh token on disk re-ran
the interactive device flow instead, a hard failure for the headless
getToken() consumer the feature exists for, not a degraded one.

load(), save() and inLock's lock bookkeeping now clear the flag for the
duration of their own file I/O and restore it on the way out. The shield
deliberately does NOT cover action.run(): that is the caller's token
refresh, and an interrupt is precisely the lever close() uses to break it.
releaseLock re-reads the flag rather than reusing the value captured
before the section, because the interrupt that matters usually arrives
during the refresh. maybeLoadFromStore latches only after a read that
COMPLETED - a missing or corrupt file still yields null without throwing,
so that answer stays definitive and is not re-read on every later call.

releaseLock also stops swallowing its IOException silently. A lock it
could not delete degrades every peer for the staleness window, which an
operator needs a line for rather than unexplained repeated sign-ins.

Two smaller fixes ride along, in blocks this change already touched:
save()'s cleanup no longer lets a failing deleteIfExists replace the write
or rename failure that is unwinding, and FakeTokenStore gains the
failLoad hook whose absence left the load-failure path untested.

Three regression tests. Without the production change the save fails with
ClosedByInterruptException wrapped as "could not persist the OIDC token to
the token store", the lock test fails with "inLock must release the lock
even when the section leaves the thread interrupted", and the retry test
fails with "no token has been obtained yet; call signIn() ...".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An orphan drainer quarantined its slot on the first 401 and dropped a
.failed sentinel, which nothing in production clears - a permanent
abandonment verdict on the slot's un-acked rows, plus a DATA_LOSS report.

That was right while the Authorization header was a fixed String: the
credential is wrong cluster-wide and waiting cannot fix it. This branch
changed the header to a Supplier<String> re-derived from the caller's
token provider on every sweep, and left every terminal-classification site
untouched. Against a rotating credential a 401 can instead be a window
that heals itself - a revocation landing mid-flight, an identity provider
rotating signing keys, a token expiring during the settle so the next pull
refreshes it - and the next sweep carries a fresh token. The drainer
inherits the sender's supplier, because startOrphanDrainers builds its
ReconnectSupplier as an inner class of the live sender, so the condition
is reachable wherever httpTokenProvider is configured.

QwpWebSocketSender.fixedAuthHeader() now tags a constant header, and
Sender routes its Basic and static-bearer cases through it, so
ReconnectFactory.hasDynamicCredential() can tell the two apart. The
drainer's 401 arm rides out DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS
sweeps with capped backoff, but only for a rotating credential; a constant
one still fails fast on the first sweep exactly as before. The budget
stays attempt-counted and deliberately short, and is never reset: a
credential that stays rejected must still reach a human rather than pin
the slot and a drainer-pool worker forever.

This does NOT repair a persistent clock skew. getToken() keeps serving the
same cached token, so those sweeps simply exhaust the budget and
quarantine - the right end state for a fault that is not healing. The
constant's javadoc says so rather than implying the window is closed.

Three tests cover it. The rotating case recovers on the third sweep and
fails without the production change with "expected same:<StubWebSocketClient>
was not:<null>". The exhaustion case pins the budget's upper end and fails
with "the budget must cap the retries expected:<6> but was:<1>". The
fixed-credential control passes with and without the change, which is what
keeps the settle budget from relaxing auth handling across the board.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adopt() rejected a persisted entry whenever its SERVED token was unusable, and
threw away the refresh token beside it. Persistence exists to preserve that
refresh token, so the process re-ran the interactive device flow where one
silent refresh would have done - a hard failure, not a degraded one, for the
headless getToken() consumer the feature is built for.

The entry shape is legitimate and reachable. Under groupsInToken=false a grant
returning only an id_token has storeTokens null the access token, and
persistIfRotated writes the entry regardless; FileTokenStore also maps an empty
on-disk value to null. A cross-language peer can produce the same file.

The two failure shapes needed telling apart, because the safe answer to each is
the opposite of the other:

  absent (null)         a legitimate shape, no evidence of anything. Keep the
                        refresh token, leave the cache empty and expired, and
                        let the refresh path do the rest.
  present but unusable  whitespace-only, or carrying a control or non-ASCII
                        char: positive evidence something else wrote the file.
                        Reject the WHOLE entry, refresh token included.

That second arm is deliberate and unchanged. Adopting the refresh token of a
file known to be tampered with would let whoever can write the store swap in
their own and have this client silently sign in as them. The six tamper tests
in OidcDeviceAuthPersistenceTest pin exactly that, and an earlier draft of this
change that folded the two arms together turned all six red.

signIn() needed the second half of the fix. It gated its silent refresh on
cachedToken != null, so even with adopt() corrected a restored entry with no
served kind still went straight to the device flow. It now attempts the refresh
whenever a refresh token is available, the same rule getToken() already applied.
A refresh that does not yield the served kind returns false and falls through to
the flow below, so this costs at most one wasted request and cannot loop.

The regression test restores an entry carrying only an id token and a refresh
token, then asserts signIn() spends the refresh and never reaches the device
endpoint. Without the production change it fails with "a usable persisted
refresh token must not force the device flow expected:<0> but was:<1>".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five fixes to the cross-process lock protocol and the on-disk format, all from
the same level-3 review. design/oidc-token-persistence.md is updated to match,
since the Python client mirrors it.

acquireLock deleted the lock by bare path whenever the exclusive create threw.
Its justification - "the create was exclusive, so the file is ours" - holds for
one of the failures that arm catches and not the others: from the second loop
iteration onward a PEER's live lock sits at that path, and fd exhaustion,
EACCES, EROFS, ENOSPC and a Windows sharing violation all arrive as a plain
IOException rather than FileAlreadyExistsException. Deleting a peer's live lock
admits a second holder, which is the double-POST of one rotating refresh token
the lock exists to prevent. It no longer deletes: a lock we really did leave
half-created is empty, and stealIfStale reclaims an empty lock on the short
grace anyway.

stealIfStale restored a wrongly-captured lock with Files.move without
REPLACE_EXISTING, and its comment claimed FileAlreadyExistsException would leave
a third party's lock intact. It would not. That call stats the target and then
renames, and rename(2) replaces silently, so a third party claiming the freed
path between the two steps had its live lock destroyed by the very call meant to
spare it. The restore now uses link(2) (Files.createLink), which fails outright
when the target exists and preserves the peer's exact bytes; a filesystem
without hard links falls back to the move. The multi-actor residual that
remains is documented rather than implied away.

stealIfStale also read a FAILED re-read of the capture as confirmation that the
lock was stale, because "the read threw" and "the lock is empty" both left
after == null. The steal then completed on the strength of an IO error, the
opposite of what its own catch block said it did. afterReadOk separates the two.

The empty-lock grace was clamped with Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS,
lockStaleMillis). That grace is the only thing standing between a peer caught
between its exclusive create and its stamp and having its live lock stolen,
which is why the frozen contract says a client MUST NOT shorten it - and any
store built with a sub-5s staleness window silently did. A short window is a
statement about abandoned STAMPED locks; the create-to-stamp gap is the same few
microseconds however the store is configured.

clear() deleted the token file but never the identity's write temps. A crash
between createTempFile and the atomic rename orphans a file holding the full
entry - refresh token included - in plaintext, and the only sweep runs from
save(), bounded by the staleness window. A caller that cleared and never signed
in again therefore left a live refresh token on disk indefinitely, contradicting
what clear() promises. It now sweeps at any age through sweepTempFiles; a temp a
concurrent save is mid-rename on is a benign loser.

parseLongOrZero accepted more than the format allows. Numbers.parseLong takes an
'L' suffix and '_' thousands separators, so "1L" parsed as schema version 1 here
and as nothing at all in every other language client - a file only this client
can read, which is the divergence a frozen format exists to prevent. It screens
for a plain JSON integer first.

Three regression tests. The acquireLock and stealIfStale fixes have none: both
need triggers I could not produce portably - a non-FileAlreadyExists IOException
from an exclusive create while a peer's lock occupies the path (ensureDirectory
re-chmods the directory on every call, undoing the read-only-directory trick),
and a three-actor filesystem race. Both are strict-safety changes: one removes a
destructive operation, the other replaces a non-atomic primitive with one that
cannot clobber.

Without their production changes the three tests fail with "clear must also
reclaim an orphaned write temp holding the refresh token", "a 1s-old empty lock
is inside the 5s grace and must not be stolen", and "expected null, but
was:<PersistedToken@...>".

The doc update also carries the storeLoadAttempted and adopt() snippets for the
two commits before this one, which those commits left stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rotating-401 ride-out for the orphan drainer lived only in
connectWithDurableAckRetry(), which runs on the initial connect and the
durable-ack capability-gap recycle. A wire drop DURING a drain is
reconnected by the ORPHAN CursorWebSocketSendLoop's own connectLoop,
where endpointPolicyFailureIsTerminal() is unconditionally true for an
orphan. So a mid-drain 401 latched a fatal SECURITY_ERROR and
BackgroundDrainer.run() quarantined the slot on the first rejection,
permanently abandoning replayable data on the exact self-healing window
(a revocation landing mid-flight, the IdP rotating signing keys, clock
skew) the ride-out exists to survive.

connectLoop now publishes an authTerminal marker for a rotating-
credential 401 on an orphan loop, mirroring how a capability gap
publishes capabilityGapTerminal: the loop still latches so run() regains
control, but run() routes the marker into connectWithDurableAckRetry()'s
bounded ride-out instead of quarantining. A constant credential and a
non-421 upgrade reject stay fatal and quarantine on the first sweep,
unchanged. Foreground senders never set the marker (it gates on
reconnectPolicy == ORPHAN), so an initializing 401 still reaches the
caller.

Each mid-drain recycle re-enters the six-attempt ride-out fresh, but
only after a successful connect and drain progress, so a persistently
revoked credential still quarantines within six consecutive failures
rather than riding out forever.

BackgroundDrainerMidDrainAuthRejectTest drives the full run() over a
real socket: a rotation that heals within the budget drains to success
(fails without this change with "expected:<SUCCESS> but was:<FAILED>"),
a persistent 401 quarantines after 2 + 6 attempts, and a constant
credential quarantines on the first sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two test-quality fixes from the level-3 review of this branch.

MockOidcServer ran each Handler on a daemon connection thread and
caught only SocketException/IOException, so an assertion inside a
handler escaped as an uncaught throwable: the client saw only a
transport drop, which it might tolerate as a silent false pass or
retry into an opaque @test timeout. handleConnection now captures the
first handler throwable, drops the connection exactly as before, and
close() rethrows it on the test thread after teardown, so a broken
handler assertion fails its test with the real cause (as the primary
failure, or suppressed on the primary when the client failed first).

QwpQueryClientTokenProviderTest constructed a QwpQueryClient, which
mallocs native scratch in its constructor, in all eleven tests without
assertMemoryLeak, unlike the sibling suite. Each test now runs under
assertMemoryLeak, proving that scratch is freed on close, including on
the connect, failover and error paths.

Both changes are test-only. OidcDeviceAuthTest (120) and
OidcDeviceAuthPersistenceTest (31) still pass under the mock change,
and a deliberately broken handler assertion now surfaces as a
suppressed AssertionError rather than being swallowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Require both the rotating-credential rejection attempt threshold and the configured reconnect dwell before quarantining an orphan slot. Normalize OIDC token-provider runtime failures to LineSenderException in the ILP and QWP query paths while preserving their causes and retry state.
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 1764 / 1961 (89.95%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/line/http/LineHttpSenderV1.java 0 2 00.00%
🔵 io/questdb/client/cutlass/http/client/HttpClient.java 0 2 00.00%
🔵 io/questdb/client/cutlass/line/http/LineHttpSenderV2.java 0 2 00.00%
🔵 io/questdb/client/cutlass/auth/BrowserLauncher.java 12 22 54.55%
🔵 io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java 2 3 66.67%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 21 26 80.77%
🔵 io/questdb/client/cutlass/auth/FileTokenStore.java 360 435 82.76%
🔵 io/questdb/client/cutlass/qwp/client/QwpCredentialUnavailableException.java 5 6 83.33%
🔵 io/questdb/client/cutlass/auth/DeviceCodePrompt.java 22 24 91.67%
🔵 io/questdb/client/cutlass/auth/OidcDeviceAuth.java 922 1009 91.38%
🔵 io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java 65 71 91.55%
🔵 io/questdb/client/cutlass/auth/TokenStoreKey.java 37 39 94.87%
🔵 io/questdb/client/cutlass/qwp/client/QwpQueryClient.java 30 31 96.77%
🔵 io/questdb/client/Sender.java 29 30 96.67%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java 37 37 100.00%
🔵 io/questdb/client/cutlass/auth/TokenStore.java 1 1 100.00%
🔵 io/questdb/client/std/str/DirectUtf8Sink.java 9 9 100.00%
🔵 io/questdb/client/std/str/Utf16Sink.java 12 12 100.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpUdpSender.java 1 1 100.00%
🔵 io/questdb/client/cutlass/auth/DeviceAuthorizationChallenge.java 12 12 100.00%
🔵 io/questdb/client/cutlass/http/client/AbstractChunkedResponse.java 10 10 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java 32 32 100.00%
🔵 io/questdb/client/cutlass/json/JsonLexer.java 65 65 100.00%
🔵 io/questdb/client/cutlass/auth/PersistedToken.java 12 12 100.00%
🔵 io/questdb/client/cutlass/auth/OidcAuthException.java 33 33 100.00%
🔵 io/questdb/client/cutlass/http/client/AbstractResponse.java 8 8 100.00%
🔵 io/questdb/client/std/str/DisplaySafe.java 20 20 100.00%
🔵 io/questdb/client/HttpTokenProvider.java 7 7 100.00%

@glasstiger

Copy link
Copy Markdown
Contributor Author

Code review — OIDC device flow (independent level-3 pass)

Reviewed at 7a95bb3a (base bc722d4f), 60 files, ~+8500/−130. Authoritative diff taken from local git (gh pr diff serves a stale cache for this branch); source read from the working tree. This is a fresh, independent pass — 10 parallel agents across all 13 review dimensions (correctness, concurrency, performance, resources, cross-context callers, tests ×3, code quality, metadata, plus a fresh-context "find what's wrong" agent), with every high-severity claim re-verified against source. It deliberately targets the newest, least-reviewed surface — the store-and-forward drainer's rotating-credential auth logic (BackgroundDrainer / CursorWebSocketSendLoop / QwpWebSocketSender), which the prior review passes predate.

Verdict: approve. No Critical or blocking issues.

The store-and-forward data-loss invariants hold: NACK/close tiering is correctly tiered (UNKNOWN fails open, no watermark advance past a NACKed frame), a transient class cannot drive a wrong quarantine (it only inflates the wall clock = more JWKS convergence time), the rotating-credential ride-out uses counters separate from the cap-gap budget, and the close()/cancel() credential-pull interrupt is a correct Dekker volatile handshake. The security surface (endpoint origin+path pinning, token-char validation, display sanitizing, hostile-file defense, response DoS bounds) held under adversarial tracing. Prior-review items are genuinely fixed (verified below).

Moderate

M-1 — Hostile chunk-size guard is incomplete: a 17-digit hex size wraps past size < 0 → silent truncation / desync. (in-diff)
AbstractChunkedResponse.java:150-159. This PR adds the if (size < 0) guard (and its comment reasons about parseHexLong's unchecked val << 4), but only handles the negative wrap. Numbers.parseHexLong has no digit-count/overflow bound, so "10000000000000000" (17 hex digits) wraps to size = 0 → hits the size == 0 chunk terminator → silent body truncation, and "...005"size = 5chunk desync. Both are >= 0 and bypass the guard. Reachable from the untrusted-IdP threat model this PR introduces. Impact is bounded (no spin — that is fixed; no injection; the 4 MiB body cap + deadline backstop it), so not Critical, but it's a real gap in hardening code the author meant to close. Fix: reject a chunk-size line >16 hex digits (or that overflows), or cap size at the max body size, before the size < 0 check.

M-2 — getToken() can stall the ILP flush hot path on an unbounded in-process lock. (in-diff)
FileTokenStore.java:288 (via OidcDeviceAuth.getToken():531 → tryRefreshCoordinated():1677 → inLock). getToken() bounds the per-instance lock (tryLock) and the cross-process file lock (degrades after a budget), but the in-process PROCESS_LOCKS entry is taken with a plain unbounded processLock.lock() — deliberately not degradable (to prevent two same-identity instances double-POSTing a rotating refresh token). So two OidcDeviceAuth instances for one identity in a JVM (the documented ILP Sender + QwpQueryClient sharing a FileTokenStore): if instance B holds the lock during a black-holed token-endpoint connect (OS-bounded, ~2 min), instance A's getToken() on the flush thread blocks the whole time. The ~2 min worst case is disclosed, but the HttpTokenProvider javadoc attributes the sub-refresh wait to the cross-process lock and omits this unbounded in-process path. Fix: bound the in-process acquire in the getToken path with a tryLock(budget) that fails fast to token-pending (the flush path tolerates that), or at least correct the javadoc. A naive unconditional bound reopens the family-revocation race, so fail-fast-to-pending is the right framing.

M-3 — recv(int) whole-call bound can turn a committed 2xx flush into a retry → duplicate rows. (effect out-of-diff; cause in-diff)
AbstractLineHttpSender.java:718 → :746. On the flush success branch, consumeChunkedResponse(response, actualTimeoutMillis) runs outside any local try; if the new recv(int) bound makes it throw while draining an already-committed 2xx chunked body, it propagates to the retry catch at :746, which re-sends the same buffer (pendingRows isn't reset until after the break) → duplicate rows. Not reachable against a real QuestDB server — ILP success is 204 No Content and consumeChunkedResponse early-returns on !isChunked() (:642); it needs an intermediary rewriting success into a slow (>30 s/fragment) chunked 2xx. Already disclosed in the PR's Tradeoffs as a marginal at-least-once widening. Verified real, but narrow. Fix (optional): on the success path, treat a recv timeout while draining an already-2xx body as non-retryable.

M-4 — The marquee rotating-401 fix is under-pinned by its regression tests. (test efficacy)
BackgroundDrainerDurableAckRetryTest (:324, :326-328, :352-353), BackgroundDrainerMidDrainAuthRejectTest:161. newDrainer uses a 60 s reconnect budget, so across the few-ms of fast attempts elapsed < budget is always true and masks the attempts < 6 clauseassertEquals(DEFAULT+1, attempts()) rides 6 failures and succeeds on the 7th for any threshold (even 1); assertTrue(attempts() >= DEFAULT) passes for 5/6/7 alike. A clause-deletion mutant on the wall-clock floor survives, the instanceof QwpAuthFailedException type-guard has no test throwing a non-421 WebSocketUpgradeException with a dynamic credential, and none of these tests carries @Test(timeout=…) — an unbounded-ride-out regression hangs the suite rather than failing. Fix: a test with a trivially-met budget asserting the exact attempts() == 6 at quarantine; a floor test with a large attempt cap; a non-QwpAuthFailedException upgrade-reject case; add @Test(timeout=…) to all ride-out tests.

M-5 — PR description contradicts the code (and its own javadoc) on WebSocket token-outage durability. (PR body)
The description says a sustained WebSocket token outage "terminates the sender for good, like any persistent reconnect failure." The code does the opposite — a running WS sender treats a credential-unavailable as transient under Invariant B and retries forever, holding rows in SF (CursorWebSocketSendLoop.java:1928-1962), and the in-code Sender.httpTokenProvider javadoc agrees ("A token outage does not terminate a running WebSocket sender"). The code is the SF-correct, data-safe behavior; the description is the outlier. Since the PR body becomes release notes and shapes the operator's data-at-risk mental model, correct it to match the code.

Minor (grouped)

Liveness / worker-pinning (all data-safe):

  • BackgroundDrainer.java:388-390: the rotating-auth ride-out quarantines only on attempts>=6 AND elapsed>=budget. Under a non-default unbounded reconnect budget (reconnectMaxDurationMillis = Long.MAX_VALUE, which the builder allows), elapsed>=budget is never true, so a genuinely-revoked credential is retried forever and the orphan slot never quarantines — contradicting the DEFAULT_MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS javadoc ("reaches a human … rather than pinning … forever") and diverging from the cap-gap sibling's OR escape (:465-467). Give it a hard attempt-cap OR term. (Default 300 s budget works; observable via the error handler regardless.)
  • An adversarial server (accept-with-valid-token → drop-before-ACK → 401-on-reconnect, repeated) resets the ride-out each recycle and can pin a worker; an always-throwing provider is retried forever by design. Consider a progress guard on the recycle.

Defense-in-depth (nil reachability today):

  • Loaded refreshToken is not char-validated in OidcDeviceAuth.adopt() (:1140, :1162) — only the served kind is. Safe today (sole egress URL-encodes it); a future non-encoding sink turns a hostile file into CRLF injection. Add hasOnlyTokenChars.
  • fetchJson discovery parses the /settings and .well-known body regardless of HTTP status (:714). Not exploitable (endpoints stay pinned); check 2xx for clearer failures.

Doc / comment cluster:

  • Three stale System.err references where the code uses SLF4J: OidcDeviceAuth.warnPersistence comment (:1681), design/oidc-token-persistence.md:537, and the persistence warning path.
  • Misleading recv-bound comment AbstractLineHttpSender.java:712-717 ("bounds each read, not the whole body cumulatively" → it bounds each recv() call, re-armed per fragment).
  • Sender.httpTokenProvider javadoc (:2327) says a WS token "must be obtainable when build() runs" — false for ASYNC initial-connect mode; and (:40) attributes the sub-refresh wait solely to the cross-process lock (see M-2).
  • firstDynamicCredentialAuthFailureNanos uses 0L as the "no failure" sentinel while the analogous cap-gap field deliberately uses -1L because nanoTime() may legitimately be 0; match the hardened convention.

Naming / metadata:

  • Boolean fields lack is/has (groupsInToken, closed, interactiveSignInInProgress, storeLoadAttempted, warnedNoPosixPerms) — getters are correctly prefixed; pervasive idiom, low priority.
  • PR title has no leading verb → feat(core): add OIDC sign-in via device flow (RFC 8628); optionally add the documentation label.

Integration nits:

  • QwpQueryClient.withBearerTokenProvider doesn't reject a double-set (:1152), unlike its ingress twin.
  • A fully auth-gated /settings blocks the unauthenticated protocol-version probe for a token-provider HTTP sender (AbstractLineHttpSender.java:284) — likely moot since QuestDB serves /settings unauthenticated; document that such users set protocol_version explicitly.
  • stampTokenIfPending leaves isTokenPending=true if authToken()/withContent() throw mid-stamp (needs a multi-MB token) → double-write on retry; clear the flag right after validateToken.
  • Interrupt-flag hygiene leak: a ClosedByInterruptException during a FileChannel read leaves the interrupt flag set past the shield (busy-spins one parkNanos); low impact (only fires in the stop protocol).

Test efficacy / coverage / quality:

  • testRejectedBuildDoesNotLeakNativeMemory is still tautological — the rejection (build():1750) precedes construction (:1777, lexer allocated last at :245), so the rejected path never allocates the lexer; the RSS-equality assertion holds unconditionally. The real leak path is covered by two sibling tests; this one only guards a construct-before-validate reorder. Restructure or drop as redundant.
  • Vacuous assertions: 7 dead assertNotEquals in OidcDeviceAuthPersistenceTest (after an assertEquals pins the value), DisplaySafeTest isDisplaySafe/isUnsafeForDisplay pairs (the latter is return !isDisplaySafe), testHttpTokenProviderAcceptedForWebSocket (asserts only the absence of one wording), no-assertion smoke tests (HttpTokenProviderTest:35, BrowserLauncherTest:43).
  • Coverage gaps: the WS supplier's validateToken reject branch (Sender.java:3442) is untested (deletable, whole WS suite still passes); cross-process file-lock exclusion has no executable guard (passes solely via PROCESS_LOCKS); clamp-boundary tests use values far from their edges (off-by-one survives); the chunk-size zero/small-positive wrap (M-1) is untested.
  • Concurrency-test correctness: testConcurrentStealContentionDegradesCleanly has no barrier (threads serialize on PROCESS_LOCKS, never actually contend); two inLock worker tests swallow spawned-thread exceptions.
  • Test-code quality: systemic multi-paragraph @Test rationale bloat (worst in LineHttpSenderTokenProviderTest, WebSocketTokenProviderTest); QwpQueryClientTokenProviderTest:249 hand-rolls a raw ServerSocket instead of reusing MockOidcServer; SEGMENT_SIZE_BYTES = 16384L should be 16_384L; MockOidcServer dribble()/stall() factories break the alphabetical run.

Checked and cleared (so they don't get re-raised)

  • Long.MIN_VALUE serializes as bare null — fixed: putLongMember uses Numbers.append(sink, value, false) (FileTokenStore.java:531); the three golden SHA-256 filename hashes recompute to an exact match.
  • Array-wrapped JSON surfaces fields at config depth — fixed: all four OIDC parsers gate every read on arrayDepth == 0.
  • Builder.httpTimeoutMillis unvalidated — fixed: now rejects <= 0 and > 120_000.
  • Blank served token reaches a Bearer header — fixed on all served paths (adopt/storeTokens/tryRefresh/selectToken via Chars.isBlank).
  • Clock-skew collapse for stored tokens — fixed (uses the stored issued lifetime, not the shrinking remaining span).
  • acquireLock deletes a peer's lock by bare path (:568) — fixed (degrades lock-free instead).
  • close()/cancel() misses a cancellation — false positive: a correct Dekker volatile handshake (cancel writes cancelled then reads credentialPullThread; the I/O thread publishes then reads cancelled), verified both ways; cancellation cannot be missed.
  • testSaveFailureThenRefreshDoesNotReplayRevokedToken is flaky (Thread.sleep(1_200) vs 1 s TTL) — refuted: the skew cap expires the token at issue+500 ms, a deterministic ~700 ms margin.
  • Endpoint-pinning bypass / token→header injection / identity confusion / hostile-file DoS — all traced clean (origin+path pin with multi-decode traversal defense, 0x20–0x7e token gate, byte-exact fingerprint re-check, 1 MiB cap + iterative lexer).

Summary

5 Moderate, ~20 Minor verified; ~9 draft findings dropped as already-fixed or false positives (including the flaky-sleep worry, refuted, and the cancel() handshake, verified correct both ways). Every Moderate is in-diff or in the PR body except M-3, whose effect lands at the out-of-diff ILP flush success path (cause in-diff). The cross-context pass walked every out-of-diff caller of the changed shared symbols (putAsPrintable/DisplaySafe, JsonLexer, Response.recv(int), DirectUtf8Sink, the QWP name-escaping paths) and confirmed them byte-identical for the ASCII/healthy-server cases the existing ILP tests exercise — 0 out-of-diff breaks. Worth doing before merge: M-1 (complete the chunk-size guard) and M-4 (pin the rotating-401 thresholds + add @Test(timeout)); M-2 / M-3 / M-5 are judgment calls best made explicitly rather than by omission.

🤖 Generated with Claude Code

@glasstiger

Copy link
Copy Markdown
Contributor Author

Code review — OIDC device flow (RFC 8628), independent level-3 pass

Reviewed at head 7a95bb3a (base bc722d4f), 56 files, +16,151/−210. Client-only (the tandem OSS #7331 / Ent #1090 were not examined). Method: independent verification of the five open Moderate findings against source, plus 10 parallel dimension agents (parsing security, store-and-forward invariants, concurrency, resources, cross-context callers, tests ×3, code quality, metadata, and a fresh-context adversarial pass). Every finding was re-verified against working-tree source; conflicting agent claims were resolved both ways.

This pass deliberately targeted the newest, least-reviewed surface — the store-and-forward rotating-credential drainer (BackgroundDrainer / CursorWebSocketSendLoop / QwpWebSocketSender).

Verdict: approve. No Critical; the one Moderate (M-5) has been addressed during this review; everything else is Minor.

The store-and-forward data-loss invariants hold: NACK never advances the ack watermark, UNKNOWN fails open, transient reconnect has no deadline, no transport error reaches a running producer, and the fixed-vs-dynamic credential gate is correct. The security core (endpoint origin+path pinning, served-kind token-char validation on every egress, display sanitizing, hostile-file defense, bounded reads) and the concurrency/resource surface (lock discipline, no jsonLexer use-after-free, a correct Dekker cancel handshake, no native leak on error/cancel paths) all held under adversarial tracing. The cross-context pass cleared every out-of-diff caller of the seven changed shared symbols — 0 out-of-diff defects.

Committed-binary gate: pass — no added/modified file is binary.


Moderate — addressed

M-5 (PR body) — the description contradicted the code on WebSocket token-outage durability. ✅ Fixed in the PR description during this review.

The body previously stated that over WebSocket "a pull that keeps failing past the sender's reconnect budget terminates the sender for good" and "a long outage ends a WebSocket sender." The shipped code does the opposite, and correctly so: in the running drainer, CursorWebSocketSendLoop.java:1928-1962 treats a QwpCredentialUnavailableException as transient under Invariant B — "retry indefinitely with capped backoff — NEVER bound by a wall-clock budget and NEVER latch a terminal" — holding the un-acked rows in store-and-forward. The in-code Sender.httpTokenProvider javadoc agrees ("A token outage does not terminate a running WebSocket sender"). The behavior the body described would itself have been a Critical store-and-forward violation (a reconnect budget dropping a producer SF promised to keep alive). Because the body becomes release notes and shapes the operator's data-at-risk model, both passages were corrected to match the code/javadoc: a running WS sender is not terminated — it retries indefinitely and buffers to SF, and a long outage grows SF/disk (eventually backpressure) rather than dropping the sender. The correct SYNC initial-connect fail-fast note was left intact.


Minor (none blocking)

1. M-1 (in-diff) — the hostile chunk-size guard is incomplete, and its comment is factually wrong. AbstractChunkedResponse.java:150-159 adds only if (size < 0), but Numbers.parseHexLong (Numbers.java:346-360) has no digit-count bound and no overflow check (val = (val<<4)+digit). So 10000000000000000 (17 hex digits) wraps to size == 0 → chunk terminator → silent body truncation, and ...0005 wraps to size == 5 → chunk desync — both bypass the guard. Severity is Minor: the guard's anti-spin purpose is met, the reachable path (untrusted IdP body) fails closed behind parseBody's 4 MiB cap + wall-clock deadline + parseLast() mid-JSON rejection, and the attacker already controls the whole body (no new capability). The in-code comment (:152-157) claiming "16 or more hex digits … wraps to a negative value" is false (7fffffffffffffff stays positive; 17 digits wrap to arbitrary non-negative values). Fix: reject a chunk-size token longer than 16 hex digits (or cap at max body size) before the guard, correct the comment, and add the two missing tests — the existing testOverflowingChunkSizeIsRejectedRatherThanSpun only exercises the negative-wrap 8000000000000000.

2. M-2 (in-diff) — getToken() on the flush path can stall on an unbounded in-process lock. The per-instance lock is bounded (acquireForGetToken, OidcDeviceAuth.java:1068), but FileTokenStore.inLock then takes processLock.lock() (FileTokenStore.java:288) — a plain unbounded ReentrantLock shared across two OidcDeviceAuth instances of one identity. If a peer holds it during a black-holed token-endpoint connect (bounded by the OS TCP-connect timeout, not httpTimeoutMillis), the flush thread blocks the whole time. Liveness/latency only, no data loss — and it is fully documented in the getToken() javadoc (:498-503, naming the exact ILP-Sender + QwpQueryClient scenario), so this is a disclosed tradeoff. Optional: bound the in-process acquire on the getToken path with tryLock(budget) degrading to token-pending (the flush path tolerates that), keeping signIn() blocking.

3. M-4 (test efficacy) — the rotating-401 regression tests leave the load-bearing clauses unpinned. BackgroundDrainer.java:388-390 quarantines only when attempts >= 6 AND elapsed >= budget. Under the 60 s FAST_RECONNECT_MAX_DURATION_MILLIS, elapsed < budget always holds, so deleting the attempt-cap clause survives every test (the wall-clock floor is tested — the earlier "floor mutant survives" note had this backwards). The instanceof QwpAuthFailedException type-guard is also unpinned (no test throws a non-421 upgrade reject with a dynamic credential), and the three direct-call rotating tests carry no @Test(timeout=…), so an unbounded-ride-out regression hangs the suite rather than failing (the MidDrain tests are guarded by a join(20s)+fail). Fix: a small-budget/large-backoff test that exhausts the wall clock in fewer than 6 attempts and asserts attempts() >= 6; a dynamic-credential non-421 upgrade-reject case; and @Test(timeout) on the direct-call tests.

4. Orphan-drainer liveness cluster (all data-safe; worth an explicit design decision). The orphan BackgroundDrainer javadoc promises a genuinely-broken credential "reaches a human … rather than pinning the slot and a drainer-pool worker forever." Three paths defeat that (rows stay safe on disk in every case; the risk is pool-worker starvation → backpressure):

  • (a) AND-vs-OR asymmetry:388-390 requires both the attempt cap and the wall-clock floor to quarantine, but under the intended "effectively unbounded" reconnect budget (Long.MAX_VALUE, contemplated at :338-348; validated only > 0 at Sender.java:2769) elapsed >= budget is never true, so it never quarantines. The cap-gap sibling (:467-468) uses OR and does not have this. Fix: cap attempts independently of the budget.
  • (b) client-side credential failure never quarantinesQwpCredentialUnavailableException (provider throws / validateToken throws) misses every typed catch and lands in catch (Throwable) at :500, classified transient → retried forever. A permanently-broken provider pins the worker — asymmetric with the server-side-401 ride-out.
  • (c) ride-out counter resets per recycle — a mid-drain 401 re-invokes connectWithDurableAckRetry() at :931, resetting the per-drain locals at :327/:330, so a flapping credential never accumulates to the threshold. The "Never reset" comment (:322-326) overstates the invariant (true within one invocation, false across recycles).

These are Invariant-B-adjacent (favor data safety over worker liveness) and may be intentional — but the javadoc promise and the server-vs-client asymmetry make them worth an explicit sign-off, or a javadoc correction if by design.

5. API parity (in-diff) — QwpQueryClient.withBearerTokenProvider accepts a silent double-set. QwpQueryClient.java:1152-1162 guards provider == null and cross-exclusion with withBearerToken/withBasicAuth, but not tokenProvider != null, so a second call silently overwrites (last-wins) — unlike its ingress twin Sender.httpTokenProvider (:2345), which throws. Add the tokenProvider != null guard for parity.

6. Test-code quality (in-diff).

  • Tautological OidcDeviceAuthTest.testRejectedBuildDoesNotLeakNativeMemory — the build() rejection precedes the constructor's lexer alloc (allocated last), so the RSS-equality assertion holds unconditionally; the sibling success test is the real leak guard.
  • QwpQueryClientPostConnectGuardTest leaks a native QwpQueryClient on each of 19 assertRejects calls (no close(), no assertMemoryLeak — it would fail today if wrapped) and reaches connected state via setAccessible reflection against the class's documented *ForTest no-reflection convention. Largely pre-existing (~2 diff lines here), but worth fixing.
  • 7 dead assertNotEquals in OidcDeviceAuthPersistenceTest (:642,669,724,748,779,804,831) — each under an assertEquals that already pins the value.
  • DirectUtf8SinkTest allocates off-heap but lacks assertMemoryLeak (its AbstractTest.tearDown() only logs).
  • testTruncatedJsonReturnsNull is trivial-pass (fingerprint check returns null before the truncation guard runs); testConcurrentStealContentionDegradesCleanly never actually contends (all threads serialize on PROCESS_LOCKS).
  • Minor: DisplaySafe mirror-pair redundant assertions; absence-only assertions in SenderBuilderErrorApiTest; WebSocketTokenProviderTest:644 Thread.sleep(50) write-before-close sync; magic numbers ≥5 digits without _ (QwpUdpSenderTest, SEGMENT_SIZE_BYTES = 16384L).

7. Metadata & misc.

  • Title lacks a leading verb: feat(core): OIDC sign-in via device flow (RFC 8628)feat(core): add OIDC sign-in via device flow (RFC 8628) (the description is copied to release notes and must read on its own). Commit 86d65ffb's title carries a feat(core): CC prefix commit titles should not (moot after squash).
  • OIDCAuthExample.java:69-70 prints the raw server message to System.err — modeling the exact unsanitized-display anti-pattern the PR defends against.
  • OidcAuthException.getOauthError() returns the raw, unsanitized error (only the message is sanitized).
  • Boolean fields without is/has (interactiveSignInInProgress, the groupsInToken field); firstDynamicCredentialAuthFailureNanos's 0L sentinel (benign — errs toward more retrying).

Downgraded (verified false positives / non-defects)

  • M-3 (recv-timeout → duplicate rows) — the throw-during-2xx-drain → retry → resend structure is pre-existing (the old no-arg recv() already threw on its default timeout; the PR only changes which timeout the success drain uses), it is unreachable against a real QuestDB server (ILP success is 204 No Content, never chunked, so consumeChunkedResponse early-returns), and it is within ILP's inherent at-least-once contract and disclosed under Tradeoffs. Not a defect this PR introduces.
  • testOversizedFileReturnsNull / testOversizedStaleLockIsStolen — not trivial-pass; both guards are load-bearing (proven by the save+load-then-oversize setup and the lock-steal decline).
  • QwpQueryClientTokenProviderTest "missing assertMemoryLeak" — all 12 tests wrap it.
  • BrowserLauncherTest "no assertion" — has assertNull(invokeSafeHttpUri(...)).
  • inLock "swallowed spawned-thread assertion" — asserting tests run inLock on the main thread; spawned-thread tests capture via AtomicReference and re-assert after join().
  • DeviceCodePrompt plain put() — safe; challenge fields are pre-sanitized at construction (OidcDeviceAuth.java:1512-1524).
  • putAsPrintableDisplaySafe at ILP callers — byte-identical for the previously-escaped set, strictly more escaping otherwise; all callers are error renderers.

Summary

1 Moderate (M-5) — addressed in the PR description during this review — plus ~15 Minor; ~10 draft findings dropped as false positives / non-defects after source verification. Every confirmed finding is in-diff or in the PR body; 0 out-of-diff defects. The recv(int) ILP-flush tightening is disclosed and correctly framed; the orphan-drainer liveness cluster is a data-safety-vs-worker-liveness tradeoff worth an explicit call. Cheap follow-ups worth doing: M-1 (complete the chunk-size guard + fix its false comment + 2 tests), M-4 (pin the attempt-cap clause + @Test(timeout)), the QwpQueryClient double-set guard, and a decision on the orphan-drainer "reach a human" holes. After eight prior review passes plus this one, no reachable data-loss, credential-leak, injection, crash, or corruption path survives.

🤖 Generated with Claude Code

@glasstiger glasstiger added duplicate This issue or pull request already exists READY and removed duplicate This issue or pull request already exists labels Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants