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>
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.
[PR Coverage check]😍 pass : 1764 / 1961 (89.95%) file detail
|
Code review — OIDC device flow (independent level-3 pass)Reviewed at 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 ModerateM-1 — Hostile chunk-size guard is incomplete: a 17-digit hex size wraps past M-2 — M-3 — M-4 — The marquee rotating-401 fix is under-pinned by its regression tests. (test efficacy) M-5 — PR description contradicts the code (and its own javadoc) on WebSocket token-outage durability. (PR body) Minor (grouped)Liveness / worker-pinning (all data-safe):
Defense-in-depth (nil reachability today):
Doc / comment cluster:
Naming / metadata:
Integration nits:
Test efficacy / coverage / quality:
Checked and cleared (so they don't get re-raised)
Summary5 Moderate, ~20 Minor verified; ~9 draft findings dropped as already-fixed or false positives (including the flaky-sleep worry, refuted, and the 🤖 Generated with Claude Code |
Code review — OIDC device flow (RFC 8628), independent level-3 passReviewed at head This pass deliberately targeted the newest, least-reviewed surface — the store-and-forward rotating-credential drainer ( 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, Committed-binary gate: pass — no added/modified file is binary. Moderate — addressedM-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, Minor (none blocking)1. M-1 (in-diff) — the hostile chunk-size guard is incomplete, and its comment is factually wrong. 2. M-2 (in-diff) — 3. M-4 (test efficacy) — the rotating-401 regression tests leave the load-bearing clauses unpinned. 4. Orphan-drainer liveness cluster (all data-safe; worth an explicit design decision). The orphan
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) — 6. Test-code quality (in-diff).
7. Metadata & misc.
Downgraded (verified false positives / non-defects)
Summary1 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 🤖 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 differ in mechanism but both keep the producer alive across a sustained token outage: over HTTP a failed pull leaves the request token-pending and is retried on the next row; over WebSocket the token must be obtainable whenbuild()runs (the initial handshake fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely, with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender, just as a persistent transport reconnect failure does not (store-and-forward Invariant B). The first pull is deferred off the build path to the first row, so the documentedconstruct → signIn() → sendordering works and a provider that throws leaves the request retriable instead of corrupting the sender.QWP egress query client —
QwpQueryClient.withBearerTokenProvider(HttpTokenProvider)accepts the same on-demand provider, soOidcDeviceAuth::getTokenplugs into the egress query path as well as ingress. The provider is queried at every WebSocket upgrade — the initialconnect()and each failover reconnect — so a long-lived query client follows token rotation; each returned token is validated before it reaches the header, and a provider that throws fails that connection attempt (matching the ingress sender). Mutually exclusive withwithBearerToken/withBasicAuth.DeviceCodePrompt/DeviceAuthorizationChallenge— how the verification URL and user code are shown. The default,DeviceCodePrompt.openBrowser(), prints the instructions toSystem.outand also tries to open the verification URL in the local default browser; the browser open is best-effort (skipped on a headless JVM, without thejava.desktopmodule, or for a non-http(s)URL, and disabled by-Dquestdb.client.oidc.open.browser=false) and never blocks or fails sign-in. UseDeviceCodePrompt.SYSTEM_OUTto print only, or supply your own to render a clickable link or a QR code, e.g. in a notebook.audience—builder().audience(...)/ discovered fromacl.oidc.audience. When set, theaudienceparameter is sent on the device-authorization and refresh requests, for providers that require it to stamp theaudclaim QuestDB expects.The token can be presented to QuestDB over any auth path the server already validates:
Authorization: Bearer <token>._ssowith the token as the password (requiresacl.oidc.pg.token.as.password.enabled=trueon the server).Discovery and trust
fromQuestDB(...)takes the IdP endpoints from the server's unauthenticated/settings, so by default it trusts that server to designate where the user signs in: a spoofed, compromised, or man-in-the-middled server could otherwise redirect the sign-in — and the long-lived refresh token — to an attacker-controlled identity provider. An optionalDiscoveryOptions.issuer(...)pin addresses this, and also covers servers that do not advertise a device-authorization endpoint. The pin separates two sources of endpoints and trusts them differently — an endpoint the untrusted/settingsadvertised is constrained to the issuer, while an endpoint read from the identity provider's own.well-knownis trusted wherever the provider hosts it:.well-knowndiscovery fallback. Current servers do not advertise the device-authorization endpoint. When it (and/or the token endpoint) is missing, a pinned issuer reads it from{issuer}/.well-known/openid-configuration. The discovery origin comes only from the caller-suppliedissuer, never from a/settings-supplied value, so a tampered/settingscannot choose where discovery — and the credential POSTs it resolves — are aimed. Without a pin, discovery is refused rather than guessed.validateEndpointOrigins, enforced on every construction path (discovery and the explicitbuilder()), requires the token and device-authorization endpoints to share one origin (RFC 8628 co-locates them on a single authorization server), so a tampered/settingsor discovery document cannot siphon one of the two credential POSTs off to a different origin./settings-advertised endpoints are pinned to the issuer. An endpoint the untrusted/settingsresponse supplied must sit on the pinned issuer's origin, and — when the issuer has a path — under that path (compared segment by segment, rejecting./.., percent-encoded traversal, and a percent-encoded path separator such as%2for%5c, at every decode level). The path check matters for a path-based provider that shares one origin per tenant (e.g. a Keycloak realm path/realms/<realm>), where the origin check alone cannot stop a tampered/settingsfrom steering credentials to a sibling tenant. The issuer is supplied out of band and cannot be forged..well-knownis neither origin-pinned nor path-scoped: that document is fetched from the pinned issuer origin and is authoritative for wherever the provider hosts its endpoints. This is deliberate — some providers (e.g. Google, Azure AD) serve their token and device endpoints from a different origin or path than the issuer, and discovery against them signs in normally. The co-location pin above still applies./settingsresponse fetched over plaintexthttpto a non-loopback host (only reachable withallowInsecureTransport) is MITM-able, so its advertised endpoints are not trusted to route credentials without an issuer pin.Without a pin, the behaviour against an
httpsserver that advertises its endpoints is unchanged: that server is trusted, as before.Security
httpsis required by default for both the QuestDB server and the IdP endpoints;httpis rejected unless the caller opts in withallowInsecureTransport(true). That opt-in relaxes only the QuestDB/settingslink — the IdP device-authorization and token endpoints always requirehttps(loopback excepted), so the device code and refresh token never cross the network in cleartext (matching the Python client).[httpStatus=…]echo, nor can a short all-digit status (2,5) be misread as a 2xx/5xx class — a malformed-length status falls through to the terminal reject path.verification_uri_complete, treated as absent) rather than shown as a blank line or handed to the browser launcher.0x20–0x7e) before it is cached, placed in theAuthorization: Bearerheader, or used as the PG-wire password — so a tampered or hostile identity provider cannot smuggle a CR/LF into the request the client then sends to the trusted QuestDB server. Only the served kind is checked; a stray character in the unused token kind, which never reaches the wire, no longer aborts an otherwise usable grant. (TheJsonLexerchange below decodes JSON escapes, which is what turns a\r/\nin a token into a real byte rather than two literal characters.)Endpoint.parserejects control characters, whitespace and display-unsafe code points anywhere in the url (so a tampered endpoint cannot inject a CR/LF into the request line or a bidi char into a log line), rejects bracketed IPv6 literals rather than mis-parsing them, rejects userinfo (user@host) — which the HTTP layer would otherwise try to connect to literally — terminates the authority at the first/,?or#so a query or fragment is never folded into the host, and range-checks the port to 1..65535.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.build()(it fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender (store-and-forward Invariant B). AgetToken()provider that fails only transiently recovers on both transports; a long WebSocket outage grows store-and-forward (and eventually applies backpressure) rather than ending the sender.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 contractReview follow-ups
A level-3 review of this branch surfaced the issues below; all are fixed here, each production fix with a regression test proven to fail without it (see the commit history for detail).
Confirmed defects:
adopt()/storeTokens()accepted a whitespace-only served token (it passedisEmpty()/hasOnlyTokenChars()vacuously), sosignIn()reported success andgetToken()served a blankBearerheader the server only answers with 401, never falling back. Now rejected viaChars.isBlank; a blank served kind is folded to absent soselectToken()surfaces the actionable error.getToken()lock contention: the unconditionaltryLock()failed fast on any lock hold, so concurrent callers sharing oneOidcDeviceAuththrew on every token refresh. It now waits briefly behind a peer's silent refresh (bounded byhttpTimeoutMillis) and fails fast only behind an interactive sign-in.Hardening and coverage:
{"config":[{...}]}can no longer surface fields at the trusted config depth.validateTokenstill re-scans every pulled token by design - a provider may mutate a reused buffer between flushes - but that scan is O(token length), runs once per flush, and is dwarfed by the network round-trip.)parseHex4non-ASCII guard, the raw..issuer-path reject, theFileTokenStoresize caps, the store-and-forward credential-timer reset, and the ILP flush whole-read timeout bound.HttpTokenProvider.getToken()now discloses the OS-bounded connect stall;TokenStore.inLockdocuments its no-reentrancy contract;FileTokenStorestates the concurrent-refresh residual (token-family revocation on a reuse-detecting IdP) instead of understating it.Tandem
OSS: questdb/questdb#7331
Ent: https://github.com/questdb/questdb-enterprise/pull/1090