feat(core): OIDC sign-in via device flow (RFC 8628)#52
Conversation
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>
…-questdb-client into ia_oidc_device_flow
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>
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>
Code review — OIDC device flow (independent level-3 pass)Reviewed at
Verdict: approve. No Critical or Moderate issues.The three highest-blast-radius changes all touch the existing ILP path and were each verified safe at their out-of-diff callers (incl. a cross-module compile of OSS
This is an unusually well-defended PR: CRLF/header injection, bidi/ANSI terminal spoofing, MITM endpoint redirection (origin + path pinning with multi-decode traversal defense), token-kind confusion, use-after-free on Minor (none blocking)
Checked and dismissed (so they don't get re-raised)
Summary~8 Minor items, none blocking: 4 test-coverage/quality gaps (#1–#4), 1 robustness nit (#5), 1 optional cold-path perf cleanup (#6), 1 PR-description prominence fix (#7), naming/comment nits (#8). ~6 draft findings dropped as false positives after source verification. Every confirmed finding is in-diff (tests / new parser code / PR body); the high-blast-radius out-of-diff callers (ILP error rendering, 🤖 Generated with Claude Code |
Address the moderate findings from the OIDC token-persistence review. getToken() runs on the latency-sensitive flush and reconnect path, but persistence routed it through FileTokenStore's cross-process lock, which could spin up to the acquire budget before a silent refresh. The advanced FileTokenStore constructor accepted an unbounded budget, so a misconfiguration could stall a flush. Cap the acquire budget at 30s, and reconcile the HttpTokenProvider SPI and the OidcDeviceAuth class docs, which still claimed getToken() never blocks: it never waits behind an interactive sign-in, but a coordinated silent refresh may briefly wait for the store lock. Add the missing tests on the security-critical persistence branches (CI had not covered these files): schema-version mismatch, per-field fingerprint mismatch (including groups_in_token and the audience nullableEquals path), the httpTimeoutMillis cap, a control-char JSON round-trip, the 0600 lock-file permissions, getToken() degrading to a lock-free refresh while a peer holds the lock, and a golden-hash anchor pinning the cross-language on-disk file-name contract. Also assert the previously-unchecked load/clear/lock store counters, the load-once guarantee, and clearCache() not reloading a just-cleared entry. FileTokenStoreTest 19 -> 26, OidcDeviceAuthPersistenceTest 14 -> 18; all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FileTokenStore released its cross-process lock by bare path, so a hold that outran lockStaleMillis (and was therefore stolen and recreated by a peer) deleted the peer's live lock on release, admitting a third acquirer alongside it and breaking the mutual exclusion the lock guards. The release now verifies ownership: acquireLock stamps a unique nonce (pid@host + timestamp + UUID) into the lock and returns it, and releaseLock deletes the lock only when it still carries that nonce. A stamp that cannot be written degrades to a lock-free refresh rather than holding an unverifiable lock. The frozen cross-language contract in design/oidc-token-persistence.md now documents the owner stamp and the ownership-verified release so other clients mirror it. OidcDeviceAuth.adopt() capped a loaded expiry but never floored it, so a tampered expires_at_millis near Long.MIN_VALUE made the validity check (now < expiresAtMillis - skew) underflow to a large positive and serve a garbage-expiry token as valid forever. adopt() now clamps the expiry to [0, now + maxLife], keeping the check underflow-safe while an already-expired entry still falls through to a refresh. Add tests: a peer-stolen lock survives our release; a tampered far-past expiry is refreshed rather than served; the parseLast() truncated- document reject; and a real FileTokenStore.save rename failure throws OidcAuthException and leaves no .tmp file. Both regression guards were proven to fail with their fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FileTokenStore.load read the file with Files.readAllBytes after a
separate Files.size check. Because the file is attacker-writable, a
concurrent grow between the two would make readAllBytes allocate
gigabytes and throw OutOfMemoryError - an Error that the best-effort
RuntimeException guard in OidcDeviceAuth.maybeLoadFromStore does not
catch, so a bad file aborted sign-in instead of degrading. A new
readBounded reads through a FileChannel into a buffer capped at the
reported size (already bounded by MAX_FILE_BYTES) plus one byte, so a
file that grew past its reported size is rejected, never allocated.
TokenFileParser tracked only object depth, so an array-wrapped object
([ {..} ]) extracted its fields through the depth gate. The parser now
marks any array as malformed and parseAndVerify rejects a shape that is
not a single flat JSON object, keeping the on-disk format strict.
TokenStoreKey now rejects a null clientId, tokenEndpoint,
deviceAuthorizationEndpoint or scope with a clear OidcAuthException
instead of surfacing a raw NullPointerException deep in a store's
serialize/fingerprint path, and normalises an empty audience to null so
getAudience(), hash() and the save/load round-trip agree that an absent
audience is null (an empty audience previously broke its own round-trip).
parseAndVerify now bulk-copies the file bytes into native memory via a
new DirectUtf8Sink.put(byte[], int, int) instead of a byte-by-byte loop.
Add tests for the array-wrapped reject, the empty-audience
normalisation, and the null-required-field rejection; each was proven to
fail with its fix reverted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sanitize the warnPersistence detail. An IO error from the store can carry the operator-supplied store path (questdb.client.oidc.token.store.dir or user.home), which could itself hold terminal-spoofing characters, so route cause.getMessage() through the null-safe sanitizeForDisplay before printing to System.err - matching how every other untrusted display string is sanitized. Reap orphan temp files. A crash between createTempFile and the atomic rename leaves a <hash>*.tmp holding a valid-at-the-time refresh token; nothing ever steals it, so they would accumulate across crashes. save() now best-effort sweeps <hash>*.tmp older than lockStaleMillis, leaving a concurrent writer's fresh temp (recent mtime) untouched - so the random per-writer suffix that keeps concurrent saves from colliding stays. Chmod the store directory only on drift. ensureDirectory re-tightens a pre-existing directory on every save and every inLock; read the permissions first and only setPosixFilePermissions when they actually differ, dropping a redundant write syscall in the common case while keeping the re-tighten defense and the non-POSIX fallback. Compare the schema version as a long. The parser narrowed it to an int, so a tampered "v" of 1 + 2^32 truncated to SCHEMA_VERSION and passed the gate; keep the full long and compare as a long. Add tests for the temp-file sweep and the version-overflow reject; each was proven to fail with its fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run clear() under the cross-process lock. A bare deleteIfExists let a peer mid-refresh atomically rename a fresh file in just after the delete, resurrecting the entry; clear() now deletes inside inLock (which cleans up its own lock and degrades to lock-free if it cannot acquire one). A Files.isDirectory guard keeps clear() a true no-op on a never-used store rather than creating the directory just to run the locked delete. Cross- process clear stays best-effort: a peer holding a live in-memory token may legitimately re-persist afterwards. Derive the adopted token lifetime from the absolute expiry. adopt() clamped expires_at_millis and token_ttl_millis from the file independently, so a tampered file could make them disagree and throw off effectiveSkewMillis (which caps the skew at half the lifetime). Derive tokenTtlMillis from the clamped expiry instead, so the skew basis always matches the authoritative remaining lifetime; a legitimate file is unaffected. Document the cross-process contract more precisely. The README now notes that lock-file staleness is judged by modification time, so coordination assumes a shared clock (a single machine or synchronized clocks; NFS clock skew can mis-judge it), and that clearCache() is best-effort across processes. The frozen on-disk contract now states the document must be a single flat JSON object, which the parser already enforces by rejecting an array-wrapped or non-object shape - so the Python client mirrors it. Order parseAndVerify before parseLongOrZero to match the alphabetical member ordering. Add tests for the expiry-derived lifetime and the clear-on-empty-store no-op; both were proven to fail with their fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review — OIDC token persistence (level-3, persistence-layer focus)Reviewed at Verdict: approve. No Critical or Moderate issues.The two highest-stakes properties hold up under adversarial tracing:
Feature coverage is strong (54 new tests, all Minor (none blocking)1. 2. 3. Test gap: the 4. Defense-in-depth: the loaded refresh token is not char-validated in 5. Nits. (in-diff)
Checked and dismissed (so they don't get re-raised)
Summary~14 draft findings verified, ~9 dropped as false positives / by-design. All 5 confirmed findings are in-diff; 0 out-of-diff defects — the out-of-diff callers of the changed shared symbols ( 🤖 Generated with Claude Code |
build() now rejects a FileTokenStore whose lockStaleMillis is below 4x httpTimeoutMillis. The cross-process lock presumes a holder has crashed once its lock outlives that staleness window and steals it; if the window is shorter than the worst-case time a live refresh holds the lock (send + await + parse + body drain, each bounded by httpTimeoutMillis), a peer can steal a live holder's lock mid-refresh and reopen the rotating-refresh-token race the lock exists to prevent. The store cannot see the client's timeout, so build() enforces the invariant where both values are known. The default store (600s) and any non-coordinating TokenStore are unaffected. Also add the two load-path security tests this subsystem lacked: - a groups-in-token instance rejects a persisted entry whose served id token carries CR/LF (adopt() must validate the id token, not the access token, in that mode) and falls back to the device flow; - a persisted served token carrying a non-ASCII char (> 0x7e), not just a control char, is rejected on load. Each new test was confirmed to fail without its fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
acquireLock stole an abandoned lock by deleting it by bare path: two acquirers contending over one stale lock could both delete it and a third could delete a peer's freshly created live lock, admitting two holders into the read-refresh-write at once. stealIfStale now captures the lock with an atomic rename to a unique name (so exactly one stealer wins), verifies the captured owner stamp matches the one it judged stale, and on a mismatch restores the peer's lock with a non-clobbering move rather than stealing it. This mirrors releaseLock's own-stamp check and shrinks the race from the whole isStale->delete gap to the gap between two renames. releaseLock read the lock file with an unbounded Files.readAllBytes. The lock file shares the attacker-writable token-store directory, so an inflated lock could throw OutOfMemoryError - an Error the best-effort RuntimeException guards on the getToken()/signIn() path do not catch, aborting the sign-in. readLockHolder now reads it with the same hard cap readBounded applies to the token file and treats an oversized lock as unreadable. Add a concurrent steal-contention test asserting mutual exclusion and no capture-temp leak, and a test that an oversized stale lock is stolen rather than wedging acquisition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FileTokenStore lock-file protocol had four edge cases where the cross-process mutual exclusion could degrade. Each degrades gracefully (at worst a re-prompt on a rotating-refresh-token IdP, never a torn or forged credential), but the fixes are cheap and the comments overstated the guarantees. sweepStaleTempFiles now skips names containing ".lock.": a steal captures a stale lock by renaming it to <hash>.lock.<uuid>.tmp, and ATOMIC_MOVE preserves the stale mtime, so a concurrent save's temp sweep would judge the in-flight capture old and delete it - destroying a lock the stealer may be about to restore to its live owner. stealIfStale now reclaims an empty/unstamped lock after a short grace (EMPTY_LOCK_STEAL_GRACE_MILLIS) instead of the full staleness window. A holder that crashes between the exclusive create and the stamp leaves an empty lock with a fresh mtime that the staleness check protected for the whole window, wedging peers into lock-free refreshes. The grace dwarfs the create-to-stamp gap, so a peer mid-stamp is never pre-empted (which would steal a lock its rightful owner is about to hold). isStale becomes the parameterized isOlderThan. The restore branch now documents honestly that a multi-actor race can momentarily admit two holders - inherent to stealing with a lock file, since a filesystem offers no atomic compare-and-delete. The comments in FileTokenStore, OidcDeviceAuth, and the design doc claimed the under-lock refresh hold is bounded by 4x httpTimeoutMillis; they now note the connection phase (DNS + TCP connect + TLS handshake) is not bounded by httpTimeoutMillis, so lockStaleMillis must clear that on top of the refresh I/O. The default 600s window already absorbs it. Adds regression tests for the empty-lock grace steal and the sweep-skips-capture behaviour; both fail on the unfixed code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review — focused re-review of
|
Address three moderate findings from the device-flow review. adopt() no longer nulls a live in-memory refresh token when a re-read store entry carries a valid served token but no refresh_token (a cross-language peer that never received one, or a tampered file). Nulling it made tryRefresh() call urlEncode(null) and throw an uncaught NullPointerException that aborted getToken()/signIn() instead of degrading to a refresh or an interactive sign-in. adopt() now keeps the current refresh token and tracks what the file actually carried; tryRefresh() also guards against a null refresh token defensively. DisplaySafe.isDisplaySafe() now rejects U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, which are neither ISO control nor Cf format chars yet break a rendered log line in ECMAScript/GUI/JSON log consumers. One classifier change closes the gap across every sanitizer that shares it (sanitizeForDisplay, OidcAuthException.putSanitized, and Utf16Sink.putAsPrintable). The httpTokenProvider Javadoc now documents that a failed HTTP flush preserves the buffered request and its baked token and re-sends it verbatim on retry rather than re-pulling, so a flush that keeps failing until the pulled token expires is then rejected (for example a 401) until the caller discards the buffered rows. Add DisplaySafeTest coverage for U+2028/U+2029 and an OidcDeviceAuthPersistenceTest regression that reproduces the refresh NPE via a peer entry omitting the refresh token (verified both ways). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Endpoint.parse now rejects an endpoint URL carrying a fragment (#...). pathOnly() stripped the fragment before the issuer-path pin while postForm sent endpoint.path verbatim on the wire, so a "#/../other/token" payload from a tampered /settings could steer the credential POST to a path the pin never validated on a server that normalizes '..'. Rejecting the fragment makes the validated path and the sent path identical. The token-store lock's stamp write and its stamp-failure cleanup now verify ownership the way release and steal already do. writeLockHolder refuses to overwrite a lock a peer stamped while this holder was pre-empted in the create->stamp gap (a long pause or cross-machine clock skew wider than the empty-lock grace), and the cleanup drops only a still-empty lock, never a peer's live one by bare path. The residual degrades to a re-prompt on a rotating refresh token, never a torn or forged credential; the design doc and the overstated grace comment are corrected to say so. The on-disk lock protocol is unchanged. Add end-to-end tests proving the httpTokenProvider token reaches the Authorization: Bearer header on the wire and rotates per request, that a failed flush re-sends the same baked token without re-pulling, and that adopt() re-saves a kept refresh token the file omitted. The endpoint fragment and re-save guards are verified both ways. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QwpWebSocketSender.buildAndConnect resolved the Authorization header inside its per-endpoint walk, so a token provider was queried once per endpoint per reconnect round. A throwing provider (a failed silent refresh, or not signed in) was then caught as a per-endpoint transport error, retried across every endpoint, and surfaced as "all endpoints unreachable" - masking the real auth failure and re-hammering the token endpoint with the same dead credential. Resolve the header once per (re)connect round, before the endpoint walk, and reuse it across a failover (a token is cluster-wide). A provider throw now propagates to connectWithRetry, which retries it within the reconnect budget - the documented streaming model - and surfaces the provider's own message. A close that races the round aborts before the possibly blocking token pull. This mirrors QwpQueryClient, which likewise resolves the credential once before its walk. Add a WebSocketTokenProviderTest case that proves the provider is queried once (not per endpoint) and its error surfaces instead of "all endpoints unreachable"; it fails against the pre-fix code. Also correct the OidcDeviceAuth.getToken() javadoc: the silent refresh is not bounded by httpTimeoutMillis end to end. That timeout bounds the send, response wait and body parse, but the preceding connection phase (DNS, TCP connect, TLS handshake) is bounded by the OS, so an unreachable token endpoint can stall the refresh for the OS TCP-connect timeout (~2 minutes), not 30s. The class already documented this for the token-store lock; the getToken() contract now matches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A review flagged several minor issues; this commit addresses the actionable ones. Endpoint.parse now rejects a non-ASCII host. String.equalsIgnoreCase folds several non-ASCII letters onto ASCII (U+0130 -> i, U+212A -> k, ...), so a homoglyph host advertised by a tampered /settings could otherwise pass the origin pin against a pinned issuer; a non-ASCII host would not resolve anyway (the HTTP layer sends it to the OS resolver as raw UTF-8, no IDNA). Endpoint.parse also lower-cases the URL scheme before matching so a case-insensitive HTTPS/Http (RFC 3986) builds, matching the browser launcher; toLowerCase folds only ASCII, so a homoglyph scheme stays rejected. Renamed sameOrigin to isSameOrigin per the is/has convention. FileTokenStore.save now retries the atomic rename on a transient Windows AccessDeniedException (a sharing violation from a concurrent reader), so a routine read/write overlap does not needlessly degrade best-effort persistence. POSIX rename over an open file is unaffected. Smaller nits: DirectUtf8Sink.put(byte[],int,int) asserts its range; AbstractLineHttpSender.close() nulls jsonErrorParser after freeing it; Utf16Sink.putAsPrintable skips a redundant charAt on the BMP fast path; and a duplicated comment in TokenStoreKey is removed. Adds OidcDeviceAuthTest cases: a non-ASCII host and an uppercase scheme (both proven to fail against the pre-fix code), and the issuer-pin accept path for host casing and an implicit vs explicit 443 port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
testConcurrentStealContentionPreservesMutualExclusion asserted a hard mutual-exclusion invariant (overlaps == 0, maxInside == 1) that the FileTokenStore cross-process lock does not provide. stealIfStale is best-effort by design and documents a three-actor residual - a peer recreating the lock while a second captures that fresh live lock and a third claims the freed path - under which two holders briefly run at once, degrading only to one extra token refresh (Layer-1 atomic rename keeps the credential intact). Under four-way contention that residual is reachable, so the test failed intermittently on CI. A stress harness confirmed the mechanism: 8 contenders overlapped in 12 of 700 iterations (maxInside 2), while 2 contenders never overlapped in 800 iterations. Split the one over-strict test in two: - testConcurrentStealContentionTwoWayPreservesMutualExclusion runs two contenders and keeps the strict overlaps == 0 / maxInside == 1 assertions. The three-actor residual cannot arise with two threads, so this is deterministic and still pins the exclusion the atomic capture guarantees. - testConcurrentStealContentionDegradesCleanly runs four contenders and asserts only the benign invariants - every contender runs its section and no capture temp leaks - tolerating the documented residual. No production change. Full FileTokenStoreTest is 40/40 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up fixes from a review of the OIDC device-flow PR. Security - Endpoint.parse rejects a URL query (?...), matching its existing fragment (#) rejection: pathOnly() strips the query before the issuer-path pin, yet postForm sends endpoint.path verbatim, so a tampered /settings could route an unvalidated query to the IdP. Correctness - tryRefresh falls back to the interactive flow when a refreshed token carries a control/non-ASCII char, instead of letting validateTokenChars propagate past the fallback. - JsonLexer.unescape keeps the backslash on an unknown or malformed escape rather than dropping it, so non-conformant text survives. - DirectUtf8Sink.put(byte[]) range-checks and throws instead of an assert that is a no-op in client apps run without -ea. - FileTokenStore warns once about missing POSIX perms via an AtomicBoolean compareAndSet, closing a benign double-warn race. Performance - AbstractLineHttpSender precomputes the User-Agent header once instead of concatenating it on every newRequest. - OidcDeviceAuth reuses build()'s parsed endpoints in the constructor instead of re-parsing the raw strings. Docs and tests - DeviceCodePrompt javadoc says "display-safe text", not "plain ASCII". - oidc-token-persistence.md reflects the shipped implementation. - Add direct DirectUtf8Sink.put(byte[]) range/bounds tests and a short 1-digit 4xx/5xx status rejection test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FileTokenStore.putLongMember wrote a long field via sink.put(long), which routes through Numbers.append(..., checkNaN=true) and renders Long.MIN_VALUE as the literal JSON null. The two long fields (expires_at_millis, token_ttl_millis) are present, non-nullable integers, so a bare null there breaks the frozen cross-language format (serialize() omits absent fields rather than writing null, so a null is indistinguishable from absent) and round-trips back to 0 on read via parseLongOrZero -- a silent corruption a Python peer would also diverge on. putLongMember now calls Numbers.append(sink, value, false), which emits the full number, so every long value round-trips verbatim. The path is reachable only through the public FileTokenStore.save(...) SPI; OidcDeviceAuth clamps both fields to a non-negative range before persisting, so the OIDC flow never hits it. Add testLongFieldsSerializeAsDigitsNotBareNull: it saves Long.MIN_VALUE in both long fields, asserts the on-disk JSON carries the digits rather than a bare null, and that load() round-trips them instead of collapsing to 0. The test fails on the pre-fix code and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address minor code-review findings on the OIDC device flow: fill test gaps around the token provider and tidy comments and dead code. No production behavior change beyond simplifying unreachable branches. Tests: - Add testRefreshedTokenWithControlCharFallsBackToInteractiveFlow: a refresh whose 200 response carries a served token with an escaped control char (JsonLexer now decodes it into a real CR) is rejected by validateTokenChars, and tryRefresh must fall back to the interactive flow rather than propagate. Guards the tryRefresh storeTokens catch; verified to fail without it. - Add testThrowingProviderOnReconnectIsRetriedAndRecovers and testPersistentlyThrowingProviderOnReconnectTerminatesTheSender: a token provider that throws on a WebSocket reconnect (the background I/O thread) is retried within the reconnect budget and recovers on a transient failure, or terminates the sender once the budget is exhausted on a persistent one. - Replace the vacuous testMalformedEndpointDoesNotLeakNativeMemory (which fed "not-a-url", rejected before the lexer is allocated) with testRejectedBuildDoesNotLeakNativeMemory (a parseable-but-rejected endpoint) and testSuccessfulBuildAndCloseDoNotLeakNativeMemory - the path that actually allocates and frees the native lexer. The new guard fails if close() stops freeing the lexer. - Add @test(timeout=30_000) to testShortAllDigitStatusNotTreatedAsTransientOrTerminal so a guard regression fails fast instead of polling to the device-code deadline. Comments and dead code: - Reword the writeLockHolder and acquireLock comments to match releaseLock's honest framing: the read-then-write and size-check-then-delete narrow, but do not close, the peer-stamp clobber window; the residual degrades to a double-refresh, never a torn credential. - Reduce pathOnly() to return Endpoint.parse(url).path and drop the now-unreachable ?/# arms in Endpoint.parse's authority terminator and path construction (Endpoint.parse rejects ? and # up front). - Expand the refreshUnderLock comment: the equals(refreshToken, lastPersistedRefreshToken) guard no longer strictly means "unsaved newer token" once adopt() keeps a live token the file lacked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
adopt() set tokenTtlMillis to the remaining span (expiresAtMillis - now), while storeTokens() sets it to the full issued lifetime. effectiveSkewMillis() caps the 30s clock-skew margin at tokenTtlMillis / 2 - a guard meant only for a genuinely short-issued (< 60s) token - so a TokenStore-loaded token in its final minute had that margin collapse toward zero, and getToken() served it on the flush path instead of refreshing. Under client/server clock drift the served token could then be rejected mid-request, and on the ILP HTTP sender that 401 is not auto-recovered. adopt() now takes the skew basis from the file's stored issued TTL (clamped to [0, maxLife]), which snapshot() already persists, so adopt() and storeTokens() give tokenTtlMillis one meaning. A tampered ttl can only shrink the skew, never inflate it past CLOCK_SKEW_MILLIS, and the server still enforces the real expiry, so trusting the stored value is no less safe than deriving it from the remaining span. Replace testAdoptDerivesTtlFromExpiry, which pinned the old remaining-span behaviour, with testAdoptTrustsStoredIssuedTtlNot- RemainingSpan, and add testAdoptedTokenNearExpiryStillRefreshesOn- FlushPath as a regression guard. Both fail on the reverted production line and pass with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the minor hardening findings from the device-flow review.
endpointPathHasEncodedSeparator() out-decoded the endpoint path with a
byte-oriented percentDecodeOnce and scanned each level for %2f/%5c/%25,
but missed encodings it does not resolve - an overlong-UTF-8 %c0%ae or
an IIS-style %u002e that a permissive server decodes to '.'/'/'. Such a
segment, sitting past the issuer prefix, slipped the path scope. A real
OIDC endpoint path is plain ASCII, so reject any '%' or '\' in it
outright; a provider that encodes its path must be configured
explicitly with builder(), which pins the origin only.
adopt() accepted an empty served token (hasOnlyTokenChars("") is
vacuously true) and would serve it as a blank "Bearer " header that
only draws a 401. Reject an empty served token too, so a corrupt or
tampered entry falls through to a refresh or an interactive sign-in.
Endpoint.parse() let Integer.parseInt read a ":+443" port as 443,
slipping the range check, and accepted a backslash in the host that the
WHATWG URL spec folds to '/'. Reject a leading '+' on the port and a
backslash in the host.
Correct the JsonLexer.unescape() default-case comment: a '\' before a
recognized escape letter is still decoded, so the "Windows path is not
dropped" note held only for genuinely unknown escapes.
Add regression tests for each - overlong-UTF-8 / %u endpoint paths, an
empty served token, and the +port / backslash-host cases; every one
fails on the reverted production line and passes with the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
[PR Coverage check]😍 pass : 1572 / 1729 (90.92%) file detail
|
Code review — OIDC device flow (level-3, client-only)Reviewed at head Verdict: approve. No Critical or Moderate issues.An unusually well-defended change — CR/LF/header injection, terminal spoofing (bidi/ANSI/zero-width/supplementary-plane tag chars), MITM endpoint redirection (origin + multi-decode path pinning), token-kind confusion, unbounded reads, use-after-free on The prior review passes' three actionable items are all fixed at this head (re-verified): Minor (none blocking) — all in-diffThe highest-value items are test-coverage gaps on correct-but-unguarded security branches.
Informational — documented tradeoffs / residuals (not defects)
Checked and cleared (so they don't get re-raised)
Summary~9 draft findings verified, ~7 false positives dropped (Java-8 breaks, 🤖 Generated with Claude Code |
Code review — OIDC device flow (level-3, client-only)Reviewed at head Verdict: approve. No Critical or Moderate issues.Three independent adversarial passes converged on the same result: no functional or security bug. The security core fails closed everywhere — served-kind token char-validation on every egress path, origin + multi-decode-path issuer pinning, Minor (all in-diff, none blocking)The two highest-value items are load-bearing guards with no regression test — both proven by build+test runs. 1. Raw (unencoded) 2. 3. Array-wrapped JSON accepted by the four discovery/response parsers (defense-in-depth, not exploitable) — 4. 5. 6. Native-leak test-guard gaps — 7. Trivial-pass tests — 8. Other coverage/quality gaps (low) — 9. Code-quality nits — Informational — documented tradeoffs / residuals (not defects)
Checked and dismissed (so they don't get re-raised)
SummaryAll confirmed findings are in-diff; 0 out-of-diff defects (the cross-context pass walked every caller of the 7 changed shared symbols and cleared each). ~24 draft findings verified, ~5 dropped as false-positive/benign. Nothing blocks merge. The two items most worth doing first are #1 (raw- 🤖 Generated with Claude Code |
Adds interactive OIDC sign-in to the Java client using the OAuth 2.0 Device Authorization Grant (RFC 8628). A process with no local browser — a remote notebook kernel, a container, a headless job — can sign a human in against QuestDB Enterprise: the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider.
On first use it prints a verification URL and a short code (and, by default, also tries to open the URL in a local browser); once the user authorizes, the token is cached in memory and refreshed silently on later calls.
What's new
OidcDeviceAuth(io.questdb.client.cutlass.auth) — runs the flow and owns the token:OidcDeviceAuth.fromQuestDB(url)discovers the client id, scope, audience and IdP endpoints from the server's unauthenticated/settings;OidcDeviceAuth.fromQuestDB(url, DiscoveryOptions)adds an identity-provider pin (.issuer(...)), a TLS config, anallowInsecureTransportopt-in, and the prompt (see Discovery and trust below);OidcDeviceAuth.builder()configures the identity provider explicitly.signIn()signs in interactively on first use, then serves a cached token and refreshes it silently;getToken()never prompts and never waits behind an interactive sign-in (safe on a request/flush path);getAuthorizationHeaderValue()returns the fullBearer …value;clearCache()drops the cached token so the nextsignIn()re-signs-in;close()cancels an in-flight sign-in (observed between polls, so it can take up to one HTTP request timeout to return). Calls are serialized by aReentrantLock;getToken()usestryLockand fails fast rather than wait behind an interactive sign-in. Token state is in-memory only by default; pass aTokenStoreto persist it across restarts (see Token persistence below).Senderintegration — newHttpTokenProviderinterface andSender.builder(...).httpTokenProvider(auth::getToken). The sender pulls a freshly refreshed token on every request, so a long-lived sender keeps working as the token rotates — unlike a fixedhttpToken(...), which is captured once and eventually starts returning 401s. Mutually exclusive withhttpToken/httpUsernamePassword. Supported over HTTP and WebSocket transport (a WebSocket sender re-queries the provider on every (re)connect/upgrade); rejected for TCP and UDP. The two transports diverge on a sustained token outage: over HTTP a failed pull leaves the request token-pending and is retried on the next row, whereas over WebSocket a pull that keeps failing past the sender's reconnect budget terminates the sender for good, like any persistent reconnect failure. The first pull is deferred off the build path to the first row, so the documentedconstruct → signIn() → sendordering works and a provider that throws leaves the request retriable instead of corrupting the sender.QWP egress query client —
QwpQueryClient.withBearerTokenProvider(HttpTokenProvider)accepts the same on-demand provider, soOidcDeviceAuth::getTokenplugs into the egress query path as well as ingress. The provider is queried at every WebSocket upgrade — the initialconnect()and each failover reconnect — so a long-lived query client follows token rotation; each returned token is validated before it reaches the header, and a provider that throws fails that connection attempt (matching the ingress sender). Mutually exclusive withwithBearerToken/withBasicAuth.DeviceCodePrompt/DeviceAuthorizationChallenge— how the verification URL and user code are shown. The default,DeviceCodePrompt.openBrowser(), prints the instructions toSystem.outand also tries to open the verification URL in the local default browser; the browser open is best-effort (skipped on a headless JVM, without thejava.desktopmodule, or for a non-http(s)URL, and disabled by-Dquestdb.client.oidc.open.browser=false) and never blocks or fails sign-in. UseDeviceCodePrompt.SYSTEM_OUTto print only, or supply your own to render a clickable link or a QR code, e.g. in a notebook.audience—builder().audience(...)/ discovered fromacl.oidc.audience. When set, theaudienceparameter is sent on the device-authorization and refresh requests, for providers that require it to stamp theaudclaim QuestDB expects.The token can be presented to QuestDB over any auth path the server already validates:
Authorization: Bearer <token>._ssowith the token as the password (requiresacl.oidc.pg.token.as.password.enabled=trueon the server).Discovery and trust
fromQuestDB(...)takes the IdP endpoints from the server's unauthenticated/settings, so by default it trusts that server to designate where the user signs in: a spoofed, compromised, or man-in-the-middled server could otherwise redirect the sign-in — and the long-lived refresh token — to an attacker-controlled identity provider. An optionalDiscoveryOptions.issuer(...)pin addresses this, and also covers servers that do not advertise a device-authorization endpoint. The pin separates two sources of endpoints and trusts them differently — an endpoint the untrusted/settingsadvertised is constrained to the issuer, while an endpoint read from the identity provider's own.well-knownis trusted wherever the provider hosts it:.well-knowndiscovery fallback. Current servers do not advertise the device-authorization endpoint. When it (and/or the token endpoint) is missing, a pinned issuer reads it from{issuer}/.well-known/openid-configuration. The discovery origin comes only from the caller-suppliedissuer, never from a/settings-supplied value, so a tampered/settingscannot choose where discovery — and the credential POSTs it resolves — are aimed. Without a pin, discovery is refused rather than guessed.validateEndpointOrigins, enforced on every construction path (discovery and the explicitbuilder()), requires the token and device-authorization endpoints to share one origin (RFC 8628 co-locates them on a single authorization server), so a tampered/settingsor discovery document cannot siphon one of the two credential POSTs off to a different origin./settings-advertised endpoints are pinned to the issuer. An endpoint the untrusted/settingsresponse supplied must sit on the pinned issuer's origin, and — when the issuer has a path — under that path (compared segment by segment, rejecting./.., percent-encoded traversal, and a percent-encoded path separator such as%2for%5c, at every decode level). The path check matters for a path-based provider that shares one origin per tenant (e.g. a Keycloak realm path/realms/<realm>), where the origin check alone cannot stop a tampered/settingsfrom steering credentials to a sibling tenant. The issuer is supplied out of band and cannot be forged..well-knownis neither origin-pinned nor path-scoped: that document is fetched from the pinned issuer origin and is authoritative for wherever the provider hosts its endpoints. This is deliberate — some providers (e.g. Google, Azure AD) serve their token and device endpoints from a different origin or path than the issuer, and discovery against them signs in normally. The co-location pin above still applies./settingsresponse fetched over plaintexthttpto a non-loopback host (only reachable withallowInsecureTransport) is MITM-able, so its advertised endpoints are not trusted to route credentials without an issuer pin.Without a pin, the behaviour against an
httpsserver that advertises its endpoints is unchanged: that server is trusted, as before.Security
httpsis required by default for both the QuestDB server and the IdP endpoints;httpis rejected unless the caller opts in withallowInsecureTransport(true). That opt-in relaxes only the QuestDB/settingslink — the IdP device-authorization and token endpoints always requirehttps(loopback excepted), so the device code and refresh token never cross the network in cleartext (matching the Python client).[httpStatus=…]echo, nor can a short all-digit status (2,5) be misread as a 2xx/5xx class — a malformed-length status falls through to the terminal reject path.verification_uri_complete, treated as absent) rather than shown as a blank line or handed to the browser launcher.0x20–0x7e) before it is cached, placed in theAuthorization: Bearerheader, or used as the PG-wire password — so a tampered or hostile identity provider cannot smuggle a CR/LF into the request the client then sends to the trusted QuestDB server. Only the served kind is checked; a stray character in the unused token kind, which never reaches the wire, no longer aborts an otherwise usable grant. (TheJsonLexerchange below decodes JSON escapes, which is what turns a\r/\nin a token into a real byte rather than two literal characters.)Endpoint.parserejects control characters, whitespace and display-unsafe code points anywhere in the url (so a tampered endpoint cannot inject a CR/LF into the request line or a bidi char into a log line), rejects bracketed IPv6 literals rather than mis-parsing them, rejects userinfo (user@host) — which the HTTP layer would otherwise try to connect to literally — terminates the authority at the first/,?or#so a query or fragment is never folded into the host, and range-checks the port to 1..65535.Content-Lengthbody dribbled through stalled TLS records, either of which previously could keep a single read running well past the deadline. After such a bounded-read abort the half-read poll connection is dropped so the next poll reconnects on a clean socket, rather than the loop spinning on the stalled response's leftover bytes until the device code expires. The device-code lifetime, the poll interval, and the token TTL are all clamped (defaults applied for absent/zero values, hard caps for absurd ones). A429with no OAuth error is treated as a transient back-off (a429that also carries a terminal error such asaccess_deniedstill aborts on the error); a transient transport failure or5xxduring polling keeps polling until the device-code deadline rather than failing the sign-in (RFC 8628; matches the Python client), while a definitive OAuth error or a terminal4xxaborts immediately. The trade-off is that a persistently flaky network is no longer cut short by a separate error budget — it polls to the device-code deadline.Token persistence (opt-in)
By default token state is in-memory only, so a restarted process re-runs the interactive device flow. Passing a
TokenStorepersists it, so the restarted process resumes from the saved refresh token (one silent token-endpoint round-trip) instead of re-prompting —getToken()then even works as the first call, with no explicitsignIn().TokenStoreSPI (io.questdb.client.cutlass.auth) —load/save/clearkeyed by a non-secretTokenStoreKey(endpoints, client id, scope, audience, groups-in-token mode), plus an optionalinLockhook for cross-process coordination. Wire it in withbuilder().tokenStore(...)orDiscoveryOptions.tokenStore(...). Persistence is best-effort: a store failure warns toSystem.errand the in-memory token is used regardless.FileTokenStore(the default) — one plaintext JSON file per identity under${user.home}/.questdb/oidc-tokens/(override withquestdb.client.oidc.token.store.dir), the refresh token protected at rest by file permissions (0600file,0700directory on POSIX) rather than encryption — the same approachgcloud,awsandghtake. The file name is a SHA-256 of the identity, so it leaks neither endpoint nor client id and several identities coexist.FileTokenStore.atDefaultLocation()/FileTokenStore.at(dir).O_CREAT|O_EXCLlock file (not an OS advisory lock, which JavaFileLockand Pythonflockcannot share); a process that cannot acquire the lock degrades to a lock-free refresh rather than stall.design/oidc-token-persistence.md): the file name, JSON schema, atomic-write and lock-file protocols are specified so the Python client (and others) can share one file.Supporting changes
JsonLexernow resolves JSON string escape sequences (\",\\,\/,\b \f \n \r \t,\uXXXX; lenient on malformed input), so string values arrive fully decoded. This also reaches the existing ILP error-response parser, which now sees decodedmessage/code/line/errorIdfields.Response.recv(int timeout)(both theContent-Lengthand 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, orContent-Lengthbytes behind stalled TLS records — cannot keep a single read running past the caller's deadline. A non-positive timeout keeps the legacy unbounded behaviour. The existing ILP flush reads go through the no-argrecv(), 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.AbstractLineHttpSenderplumbs the token provider through with a deferred, retriable per-request pull (a throwing or blank-returning provider leaves the request token-pending for the next row instead of corrupting the half-built request), so the very first send already carries a provider-sourced token. Its error rendering now routes every untrusted server-supplied string throughputAsPrintable— the decoded JSON error body, the[http-status=…]field, and the line-protocol-version detection probe body — escaping control and Unicode format characters (bidi overrides, zero-width joiners, the BOM), so a hostile or proxied endpoint cannot reorder, hide, or forge the text shown in aLineSenderException(or spliced into a log line or terminal).QwpWebSocketSendersources its auth header from the token provider too, re-querying it on every connect/reconnect so a rotating token keeps a long-lived WebSocket sender authenticated.Tradeoffs and limitations
fromQuestDB(...)discovery trusts an endpoint read from the issuer's.well-knownwherever the provider hosts it, so an off-origin provider (e.g. Google) signs in normally through a pinned issuer; only an endpoint the/settingsresponse itself advertised is held to the issuer's origin and path. The explicitbuilder().issuer(...)pin is stricter — a plain sanity check that both supplied endpoints sit on the issuer origin — so an off-origin provider configured that way must have its issuer omitted, or its endpoints supplied to match. This matches the Python client.getToken()provider that fails only transiently recovers on HTTP; a long outage ends a WebSocket sender.MockOidcServer.dropConnection()).127.xaddress that the loopback classifier deliberately rejects as non-loopback. That trick relies on the OS resolver expanding the short form (BSDinet_aton, on Linux/macOS), which Windowsgetaddrinfodoes not do, so that one end-to-end test is skipped on Windows; the loopback classifier itself is covered cross-platform.TokenStorebacked by an OS keychain or a secrets manager instead ofFileTokenStore. On Windows POSIX permissions cannot be enforced, so the file currently relies on the user-profile directory's default ACL (owner-only ACL hardening is a follow-up); the client warns once toSystem.err.getToken()may briefly wait to acquire the cross-process lock before a silent refresh (a few seconds at most forFileTokenStore— the acquire budget is capped — then it proceeds without the lock). It still never waits behind an interactive sign-in; this is a quick silent refresh, not an interactive wait.Response.recv(int)whole-read bound (see Supporting changes) also tightens existing, non-OIDC ILP flushes. The no-argrecv()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 retryableHttpClientExceptionthatflush0catches 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; runnableOidcDeviceFlowExample/OIDCAuthExample; and README "OIDC Sign-In (Device Flow)" and "Persisting the Token Across Restarts" sections. Coverage includes:.well-knowndiscovery via a pinned issuer; a discovery document that omits the device-authorization endpointbuilder().issuer(...)origin pin rejecting off-origin endpoints; a/settings-advertised endpoint rejected when off the issuer origin; an endpoint discovered from the issuer's.well-knownaccepted even when off the issuer origin (the Google case)%2f), and a percent-encoded backslash (%5c) rejectedhttp(firing path skipped on Windows)audienceparameter discovered from/settingsand sent on the device and refresh requests429and a transient5xx/transport failure keep polling to the deadline; a terminal4xxand an OAuth error fail fast (including a429that also carries a terminal error);slow_downgrowth and the 60 s interval clamp; device-code-lifetime and clock-skew clampsEndpoint.parserejecting a malformed url: userinfo (user@host), a bracketed IPv6 literal, an out-of-range port, and control/whitespace/display-unsafe characters2,5) that must not be read as a 2xx/5xx classverification_uri_completethat sanitizes to empty treated as absentJsonLexerescape decoding, including a\uXXXXescape split across two parse fragments, and the lenient/exotic escape armsgetToken()failing fast while another thread holds the lock in an interactive sign-in or a silent refresh; native-memory cleanup on the error/rejection construction paths0600/0700permissions and control-char JSON escaping; a corrupt, empty, oversized, schema-version-mismatched, or per-field fingerprint-mismatched file ignored; a tampered far-future expiry clamped and a CR/LF served token rejected on load; a restart serving a valid persisted token (or silently refreshing an expired one) without re-running the device flow; rotating vs non-rotating refresh write behaviour; a swallowed save not replaying a revoked token; the cross-process lock-file protocol (acquire, mutual exclusion, stale-steal, degrade);getToken()degrading to a lock-free refresh when a peer holds the lock; the HTTP-timeout cap; the file-name hash pinned as a cross-language contractTandem
OSS: questdb/questdb#7331
Ent: https://github.com/questdb/questdb-enterprise/pull/1090