feat(oidc)!: C client adds OIDC device flow - #183
glasstiger wants to merge 248 commits into
Conversation
Add an `oidc` feature providing `OidcDeviceAuth`, an interactive OIDC sign-in helper for QuestDB Enterprise via the OAuth 2.0 Device Authorization Grant (RFC 8628). It prints a short code and verification URL (opening a browser when one is available), the user authorizes on any device, and the client acquires and silently refreshes a Bearer token — so it works from headless or remote environments. Wire the token into an ILP/HTTP sender with the new `SenderBuilder::http_token_provider`, a per-flush callback that supplies a fresh Bearer token so a long-lived sender keeps working as the token rotates. It is mutually exclusive with username/password and token auth. The `oidc` feature pulls in `sync-sender-http`; all identity-provider calls are held to https (or loopback http), and untrusted IdP fields are sanitized before display. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OidcError::kind() returns OidcErrorKind, but the type lived in the private `error` module and was re-exported nowhere. External callers could call .kind() but had no path to name OidcErrorKind, so they couldn't match on it — the whole typed-error hierarchy (Config, Network, DeviceFlow, Timeout, InteractionRequired) was unusable outside the crate. clippy can't catch this (unnameable_types is nightly, allow-by-default). Add it to the oidc re-exports, and add a doctest on kind() that names both types via the public path. A doctest compiles as a separate external crate, so it guards the re-export against regression — an in-crate test could name the type regardless of whether it is exported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TokenSet was public and re-exported with documented accessors (expires_at/token_type/scope) and a secret-redacting Debug, but every function returning it was private — no public API could produce one, so the type and its accessors were dead external surface. Add a read-only `token_set()` snapshot of the cached tokens. It never prompts, acquires, or refreshes and never blocks behind an in-flight sign-in, so it's a cheap way to inspect token metadata (expiry, scope). A no_run doctest exercises the accessor through the public path, guarding the reachability the type was designed for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
endpoint_path_under_issuer normalized each endpoint segment with strip_matrix_params (which trims) *before* running its fail-closed per-segment checks. A percent-encoded trailing whitespace byte defeated the pin: `.../realms/prod%20/token` (also %09, %0a) decoded to segment `prod `, trimmed back to `prod`, passed the checks, and matched issuer segment `prod` — even though a server that routes `prod ` as a distinct tenant would send the request to a different realm. The %0a case slips past the has_control_char guard that is specifically meant to catch a newline, because the trim removes it first. Run the checks on the decoded, un-trimmed segment and additionally fail closed when a segment has leading/trailing whitespace (the `%20` space case the control-char guard alone misses). Only the endpoint side (the /settings- or non-https-IdP-supplied, attacker-influenceable input) is tightened; the issuer base is the trusted out-of-band pin. Exploitation is narrow (needs a co-located attacker tenant whose name is a whitespace variant the IdP routes as distinct), but it is a real deviation from a stated security invariant in tenant-isolation code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tokenset_from_response capped every cached token's believed lifetime at MAX_EXPIRES_IN (1h) regardless of the IdP's real TTL. Under the default scope (openid, no offline_access) the IdP issues no refresh token, so once the cap fired obtain_tokens found nothing to refresh with and fell through to a fresh interactive sign-in — which in a headless/CI context returns InteractionRequired. Net effect: a Sender::flush that was working failed after ~1h, mid-flush, even though the access token was still valid at the server. That is exactly the advertised headless use case. The cap's security value only exists *with* a refresh token: it bounds how long a leaked long-lived access token is used before a silent rotation. With no refresh token, capping the client's belief about expiry can't shorten the token's real validity at the server — it only forces a pointless re-prompt. So apply the cap only when a refresh token was issued; otherwise trust the IdP's real expires_in. Update the constant comment and the module "Token lifetime and refresh" docs to match, and add lifetime_cap_applies_only_with_refresh_token (asserts expires_at - issued_at is the full TTL with no refresh token, and MAX_EXPIRES_IN with one). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The riskiest surface was largely asserted only on the happy path. Add coverage for the reject-paths, all driven through the existing loopback mock or as pure-function checks: IdP .well-known discovery (was entirely untested; every prior test passed endpoints explicitly): - idp_discovery_supplies_endpoints — happy path: doc issuer matches the pin, endpoints are discovered and used (anchors the reject tests). - idp_discovery_issuer_mismatch_rejected — doc declares a different issuer than the pin → refused. - idp_discovery_missing_device_endpoint_rejected — doc omits device_authorization_endpoint → clear failure, not a half-built config. - discovery_without_issuer_pin_rejected — /settings advertises no endpoints and no issuer is pinned → refused up front. reject_confusable_authority: - confusable_authority_percent_rejected — `%` in the authority is refused. - confusable_authority_empty_rejected — empty/absent authority is refused (negative-proofed to exercise the is_empty() arm via a path-only URL). Plaintext /settings guard: - loopback_plaintext_settings_endpoints_allowed — exercises the guard's reachable (loopback-exemption) branch end-to-end. The non-loopback trigger stays covered by plaintext_non_loopback_settings_channel_flagged: the guard sits after a successful /settings fetch, so an in-process test can't host a channel that is both plaintext-rejected there and reachable. Tests only; no production code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (m10)
m9: body_snippet echoed up to 200 control-stripped chars of a non-JSON
response body into a diagnostic error. A valid token response is JSON and
never reaches this path (only a JSON parse failure snippets), so it only
ever quotes an intermediary's error page — low risk, but cap it at a named
MAX_BODY_SNIPPET_CHARS = 120 to shrink what an unexpected token-endpoint
body could spill into a log while keeping the snippet useful.
m10: the example's split_host_port mis-parsed an IPv6 literal
(https://[::1] became host "[:") and included `user@` userinfo in the host.
Strip userinfo (host is after the last '@') and handle a bracketed IPv6
address, keeping the brackets since the sender interpolates "{host}:{port}"
verbatim. Verified against IPv6 with/without port, userinfo, and plain
host:port. Example code only.
m7 (empty .scope("") filtered like audience) and m8 (maybe_open_browser
reaping the child) were already fixed in the base device-flow commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hardening
m1: an empty error_description erased the IdP error code from the failure
message. `.or(error)` treated Some("") as present, so a response of
{"error":"access_denied","error_description":""} produced "Device flow
failed: " and Display dropped the code too. Filter the empty description
before the fallback, and normalize empty (or all-control) error/
error_description to None in with_idp_error so it can't shadow the code in
the message, Display, or the accessors.
m2: a `slow_down` bundled into an HTTP 429 (rather than the conformant 400)
with a low Retry-After was handled by the generic 429 branch with
at_least_increment=false, so the poll interval could drop — violating the
RFC 8628 MUST-increase. Pass the slow_down flag to backoff() in that branch.
m3 (docs): the re-entrancy/self-deadlock warning named token()/clear() but
omitted sign_in() (deadlocks identically) and the token-provider-on-flush
re-entry route. Name both in the renderer and concurrency docs. (try_lock
was considered and rejected: the acquire lock also serializes legitimate
cross-thread callers, which must block, not error.)
m4 (defense-in-depth): discover_from_idp skipped the issuer cross-check when
the .well-known doc omitted issuer (RFC 8414 requires one), trusting its
endpoints. Fail closed on an absent issuer, keeping the mismatch message.
Tests: empty_error_description_keeps_error_code,
slow_down_via_429_still_increases_interval (mock gains Retry-After header
support), idp_discovery_without_doc_issuer_rejected; the missing-device-
endpoint test now supplies a matching issuer. All three correctness fixes
negative-proofed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
m5 (defense-in-depth): the plaintext-/settings guard fired only when a credential endpoint came from /settings, but client_id/scope/audience/ groups are read from the same tamperable cfg. Fire the guard on any settings-sourced value (still gated on plaintext non-loopback channel + no issuer pin), since a tampered channel can force a wrong client registration or mis-scoped tokens, not just redirect the endpoints. m6 (docs): warn on http_token_provider that the bearer token is sent in cleartext over Protocol::Http to a non-loopback host — use Https. m7a: HttpAuth::resolve rejected only an empty provider token; trim so an all-whitespace token is rejected too (matching safe_token). m7b: floor the device-code lifetime (MIN_DEVICE_CODE_LIFETIME = 60s) so a hostile expires_in:1 can't abort the flow after a single poll. Well below any conformant code lifetime, so it never shortens a legitimate one. m7c: report the precise "http_token_provider is mutually exclusive" error when a provider is combined with a half-specified basic auth (username, no password), instead of the generic "password missing" — checked at the top of build_auth so setter order doesn't matter. m8a: silent_refresh_without_reprompt now asserts the omitted-on-refresh refresh token is carried forward (the path it was already exercising). Tests added for m7a/m7b/m7c; all three correctness fixes negative-proofed. Not changed (deliberate): m7d (infallible OidcDeviceAuthBuilder setters — converting to fallible -> Result<Self> is a breaking API change for a style preference); m8b (the e2e's ~5s wait is the security-floored poll interval — shrinking the mock's expires_in is fragile under CI load and conflicts with the m7b floor, and a public sleep seam adds semver surface for one test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The one-hour cap (MAX_EXPIRES_IN) keyed off the refresh token present in the token response body. A refresh from a non-rotating IdP omits the refresh token and relies on the client carrying the prior one forward, so the cap was skipped even though the resulting token set does hold a (carried-forward) refresh token — leaving the believed lifetime at the IdP's full TTL for the rest of the sender's life, defeating the documented hourly re-check in its common case. tokenset_from_response now takes the prior refresh token and applies the cap to the effective token (fresh or carried forward), unifying the carry-forward and cap decisions in one place. Adds a regression test for the carried-forward path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OidcErrorKind is a new public enum whose documented use is matching on kind(). Marking it #[non_exhaustive] lets a future variant be added without breaking a downstream exhaustive match, matching the convention already used for the crate's other public enums (egress errors, ingress::Protocol). No in-crate change is needed since #[non_exhaustive] only constrains downstream crates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up hardening from code review: - Length-cap untrusted IdP error / error_description fields (new render::strip_control_capped + MAX_IDP_FIELD_CHARS) in messages and on the error itself. A JSON error_description is bounded only by the 4 MiB response cap and bypassed the non-JSON body-snippet cap, so a hostile IdP could produce a multi-MB error string. - Poll loop: after 3 consecutive transport-level failures (no HTTP status — connection refused, TLS, DNS, reset) surface the real network cause instead of polling until the device code expires and reporting a misleading timeout. Any HTTP response resets the counter, preserving the RFC-transient handling of 5xx/429. - Clear any stale cached token before the interactive flow unconditionally, covering the no-refresh-token path (previously it was cleared only on the refresh fall-through), so a failed sign-in never leaves an expired token cached. - Reap the browser opener via thread::Builder::spawn with the error swallowed, so OS thread exhaustion can't panic the sign-in. - Document that a token-provider re-prompt runs a full device flow inside flush() and can block up to the device-code lifetime; recommend sign_in() up front and offline_access. - Tests: OidcErrorKind::Timeout (expired_token body branch + the deadline branch via a new test-only clock hook), a direct parse_retry_after table test, provider-resolved-once-per-flush across a retry, request_device_code error paths, and the poll-loop non-JSON / 3xx-redirect terminal branches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Token state was in-memory only, so a restarted process re-ran the interactive device flow. A new opt-in TokenStore persists it, so a restart resumes from the saved refresh token (one silent token-endpoint round-trip) and OidcDeviceAuth::token() even works as the first call. New questdb::oidc::token_store module: - TokenStore trait (load / save / clear / in_lock) — implement over an OS keychain or a secrets manager for at-rest encryption; PersistedToken (secret-redacting Debug) and TokenStoreKey (canonical endpoints, order- normalised scope, cross-language SHA-256 file-name hash). - FileTokenStore, the default: one plaintext JSON file per identity under ~/.questdb/oidc-tokens/ (or QUESTDB_CLIENT_OIDC_TOKEN_STORE_DIR), 0700 dir / 0600 file created with those modes. Layer 1 (always): atomic temp-write + fsync + rename, omit-null schema with *_millis, a 7-field fingerprint re-check, and a size-bounded defensive load (1 MiB cap, O_NONBLOCK FIFO guard, symlink-leaf refusal). Layer 2 (rotating-refresh IdPs): an O_CREAT|O_EXCL <hash>.lock critical section with mtime-staleness steal and degrade-on-contention. SHA-256 via the required crypto provider (ring / aws-lc, cfg-branched). The on-disk format (file name, JSON schema, atomic-write and lock-file protocols) matches the frozen cross-language contract shared with the QuestDB Java and Python clients, so a file written by one is read by another. OidcDeviceAuth integration (opt-in .token_store(...)): lazy-load once under the acquire lock, run the refresh inside store.in_lock (re-read a peer's entry, adopt or refresh), persist only on refresh-token rotation, and clear() deletes the entry. A persisted file is untrusted input: served tokens are re-validated for control / non-ASCII chars and the expiry clamped to at most one hour. All best-effort — a store failure warns and never fails an otherwise-valid in-memory sign-in. Persistence writes a long-lived refresh token to disk in plaintext, so it is opt-in and documented as such. Tests: 20 store unit tests (frozen hash pinned to a byte-exact value; atomic write, 0600/0700 perms, fingerprint / oversize / corrupt / version rejects; lock mutual-exclusion and stale-steal) and 5 device-level tests (restart resume, silent refresh on restart, rotating vs non-rotating write, clear, tampered-load rejection). Verified across ring, aws-lc-crypto, and the default (module gated out); the FFI crate is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only ILP/HTTP could pull a rotating OIDC token (http_token_provider); the QWP/WebSocket ingress sender and the egress QWP reader used a static Authorization header frozen at build. Both now accept a token provider, pulled fresh at each (re)connect handshake, so a long-lived client keeps a valid Bearer as the token silently rotates. - New crate-level token_provider module: a TokenProvider newtype (a boxed Fn() -> Result<String>, secret-redacting Debug) with bearer_header(), which pulls a token, rejects a blank / non-printable-ASCII value (a decoded CR/LF is a header-injection vector), and formats "Bearer <token>". - QWP/WS ingress: SenderBuilder::qwp_ws_token_provider stores the provider on QwpWsConfig; it is resolved at connect_qwp_ws_endpoint_round — the single chokepoint every initial connect, reconnect, retry, and orphan-drain reopen funnels through — overriding any static basic/token auth. Mutually exclusive with username/password/token. - Egress reader: ReaderConfig::token_provider stores the provider; resolved in upgrade_headers (now fallible) on every connect and failover, overriding static auth. The provider's crate error is mapped into the reader's own egress error type. Also documents using the token beyond ILP/HTTP: as a PG-wire password with the username _sso (requires acl.oidc.pg.token.as.password.enabled=true on the server; validated at connect time, so only new connections need a fresh token), and via authorization_header_value for raw HTTP. The ILP/HTTP http_token_provider path is unchanged. Tests: the reader header override / rejection / error-propagation, the QWP/WS builder mutual-exclusion, and the shared bearer_header validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refresh_under_lock could adopt a re-read persisted entry as the refresh
source even when it carried only a served token and no refresh token
(a supported, untrusted on-disk state). refresh() then hit
.expect("refresh() called without a refresh token"), unwinding out of
the caller's token()/flush() on the hot path.
Guard the peer adoption so a refresh-token-less peer is never made the
refresh source (keep the known-good in-memory token, which the
obtain_tokens gate guarantees has one), and make refresh() return an
error instead of panicking as defense-in-depth. Add a regression test
that reproduces the panic against the old code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rotating token provider is pulled on every (re)connect for the QWP/WebSocket sender and the egress reader, and each drives a retry/abort decision off the error's code — but they mapped a provider failure oppositely. The egress reader force-mapped every provider error to a terminal AuthError, so a transient network blip during an OIDC silent refresh permanently killed a long-lived reader. The QWP/WS sender propagated the raw code, but its reconnect classifier treats only AuthError/ProtocolVersionError as terminal, so a permanent ConfigError was retried every reconnect round instead of aborting. Centralize one policy in TokenProvider::bearer_header: keep a transient SocketError (both transports treat it as retry-eligible) and normalize every other failure to a terminal AuthError. The egress boundary now preserves that code across the crate->egress error types instead of flattening it. Fixes the stale QWP/WS comment and adds tests for both the transient and permanent cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M3: bound the believed token lifetime by the served token's own JWT `exp` on both the wire and persisted paths. A non-conformant or hostile `expires_in` (which can saturate to a near-infinite lifetime when no refresh token caps it) no longer wedges the sender on a permanently "valid" dead token; groups mode now follows the id_token's exp rather than the access token's `expires_in`; and a legitimately long-lived no-refresh entry persisted by another QuestDB language client is no longer needlessly capped to an hour on load. Opaque (non-JWT) tokens keep the prior behavior (the IdP's `expires_in` on the wire, the 1h untrusted-file cap on load). M2: maybe_load_from_store set load_attempted before the load, so a transient I/O error — the only case a store surfaces as Err, since missing/corrupt/oversized files are Ok(None) — permanently disabled persistence and forced an interactive re-prompt despite a usable on-disk token. Set the flag only after a successful load so a transient failure is retried. Adds tests for the wire/groups/persisted exp bounds and the transient-load retry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…auth M4: mark the public OidcConfig #[non_exhaustive]. It is only ever handed back by OidcDeviceAuth::config() for inspection (constructed solely inside the crate), so this is non-breaking now and lets a future field stay non-breaking too — matching OidcErrorKind and the egress config types. M5: ReaderConfig::token_provider now returns Result<Self> and rejects being combined with static username/password/token auth from the config string, instead of silently overriding it. This aligns the reader with SenderBuilder::http_token_provider / qwp_ws_token_provider, which already reject the same combination, so the same misconfiguration behaves the same way across ingress and egress. The egress reader API is unreleased, so the signature change breaks nothing shipped. M6 was already resolved by the JWT-exp expiry bound (prior commit): in groups mode the served token is the id_token, whose exp now bounds the believed lifetime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Release the cross-process lock by inode identity, not by path. If a holder is suspended past the stale window and a peer steals + recreates the lock, the original's by-path unlink would delete the peer's live lock and let a third process enter concurrently. release_lock now compares (dev, ino) of the held handle against the path and only unlinks a lock it still owns (unix; best-effort elsewhere). - Clamp with_lock_timings' acquire_budget to 5 minutes so a near- Duration::MAX value can't overflow Instant::now() + budget. - Reset the consecutive-transport-failure counter on a reachable-but- erroring poll (a non-JSON 5xx/429). Previously only the Ok branch reset it, so an interleaved error page could trip the misleading "unreachable on N consecutive polls" abort. - Close the leaf-symlink TOCTOU in ensure_directory: create the parent chain recursively, then the leaf non-recursively, so a symlink planted in the stat->create window fails (EEXIST) and is re-checked instead of being followed by a recursive create. - Consolidate the printable-ASCII / non-blank Bearer gate (was duplicated 4x) into one crate::is_printable_ascii_token helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # questdb-rs/Cargo.toml # questdb-rs/build.rs # questdb-rs/src/ingress.rs # questdb-rs/src/ingress/conf.rs # questdb-rs/src/ingress/sender/qwp_ws.rs # questdb-rs/src/lib.rs
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe change adds RFC 8628 OIDC device authorization, secure discovery and HTTP support, persistent token storage, rotating Bearer-token providers, and C/C++ integrations for QuestDB transports. ChangesOIDC authentication and rotating token providers
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Sequence Diagram(s)sequenceDiagram
actor Client
participant OidcDeviceAuth
participant HttpClient
participant IdentityProvider
participant FileTokenStore
participant SenderBuilder
participant QuestDB
Client->>OidcDeviceAuth: sign_in()
OidcDeviceAuth->>FileTokenStore: load cached token
OidcDeviceAuth->>HttpClient: request device authorization
HttpClient->>IdentityProvider: POST device authorization
IdentityProvider-->>OidcDeviceAuth: device code and token response
OidcDeviceAuth->>FileTokenStore: save token
Client->>SenderBuilder: configure token provider
SenderBuilder->>OidcDeviceAuth: authorization_header_value()
OidcDeviceAuth-->>SenderBuilder: Bearer header
SenderBuilder->>QuestDB: send authenticated row
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (1)
questdb-rs/src/oidc/device.rs (1)
929-942: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winThe loop sleeps before the first poll, so every sign-in waits at least one interval.
intervalis floored atMIN_POLL_INTERVAL(5s). The sleep runs before the first token request, so an instant user authorization still costs 5 seconds. The integration test inquestdb-rs/tests/oidc_device_flow.rs(line 366) records this cost. Poll first, then sleep before each retry.This changes the sleep sequence, so update
slow_down_via_429_still_increases_intervalinquestdb-rs/src/oidc/device/tests.rs, which indexesdurations[0]anddurations[1].🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@questdb-rs/src/oidc/device.rs` around lines 929 - 942, The device authorization loop should poll for the token immediately instead of sleeping before the first request. Update the flow around the loop’s remaining-time calculation and sleep so the delay occurs only before retries, while preserving expiration handling and interval adjustments. Revise slow_down_via_429_still_increases_interval to assert the new sleep sequence rather than assuming the first two durations are pre-poll waits.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@questdb-rs/src/egress/config.rs`:
- Around line 1081-1090: Update the ReaderConfig TLS documentation example
around ReaderConfig::from_conf to use the supported wss:: scheme instead of
qwpwss::. Also replace any surrounding qwpwss:// references with wss://, leaving
the rest of the example unchanged.
- Around line 1096-1110: Update ReaderConfig::validate to reject configurations
where token_provider is Some and auth is not AuthMode::None, returning the
existing configuration error style. Preserve acceptance when either the provider
is absent or auth remains AuthMode::None, and keep token_provider unchanged.
In `@questdb-rs/src/ingress.rs`:
- Around line 2977-2985: Apply the same mutual-exclusion validation used by
SenderBuilder::build to the pooled connector path, preferably within
resolve_qwp_ws_ingredients so both connection flows reject qwp_ws.token_provider
combined with static username/password or token authentication. Preserve the
existing ConfigError message and avoid silently preferring the rotating
provider.
In `@questdb-rs/src/ingress/mod.md`:
- Around line 217-220: Update the token provider documentation in the sender
guidance to state that the sender resolves a fresh token once per flush and
reuses that token for any retry requests within the flush, rather than resolving
it on every request.
In `@questdb-rs/src/oidc/device.rs`:
- Around line 366-369: Update the token-state documentation near the builder’s
token_store configuration to qualify the in-memory-only statement: state does
not survive process restarts when no persistent token_store is provided, but may
survive when a persistent store is configured.
- Around line 1168-1205: Update the expiry calculation in the token-response
flow around `expires_in` and `expires_at` so opaque served tokens without a
refresh token still receive an absolute `MAX_EXPIRES_IN` ceiling. Preserve the
JWT `exp` bound when available, and ensure the resulting expiry matches the
bounded behavior already used by `tokenset_from_persisted`.
In `@questdb-rs/src/oidc/token_store/tests.rs`:
- Around line 51-75: The test hash_matches_frozen_cross_language_value should
reference the shared Java/Python fixture that defines the pinned digest values.
Add the existing fixture reference or link alongside the frozen hash assertions,
without changing the test inputs or expected hashes.
---
Nitpick comments:
In `@questdb-rs/src/oidc/device.rs`:
- Around line 929-942: The device authorization loop should poll for the token
immediately instead of sleeping before the first request. Update the flow around
the loop’s remaining-time calculation and sleep so the delay occurs only before
retries, while preserving expiration handling and interval adjustments. Revise
slow_down_via_429_still_increases_interval to assert the new sleep sequence
rather than assuming the first two durations are pre-poll waits.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d59431d6-3dfe-4b40-8bce-4a306eebd6be
📒 Files selected for processing (26)
questdb-rs/Cargo.tomlquestdb-rs/build.rsquestdb-rs/examples/oidc_device_auth.rsquestdb-rs/src/egress/config.rsquestdb-rs/src/egress/transport.rsquestdb-rs/src/ingress.rsquestdb-rs/src/ingress/conf.rsquestdb-rs/src/ingress/mod.mdquestdb-rs/src/ingress/sender.rsquestdb-rs/src/ingress/sender/http.rsquestdb-rs/src/ingress/sender/qwp_ws.rsquestdb-rs/src/ingress/tests.rsquestdb-rs/src/ingress/tls.rsquestdb-rs/src/lib.rsquestdb-rs/src/oidc/device.rsquestdb-rs/src/oidc/device/tests.rsquestdb-rs/src/oidc/discovery.rsquestdb-rs/src/oidc/error.rsquestdb-rs/src/oidc/http.rsquestdb-rs/src/oidc/mod.rsquestdb-rs/src/oidc/render.rsquestdb-rs/src/oidc/token.rsquestdb-rs/src/oidc/token_store.rsquestdb-rs/src/oidc/token_store/tests.rsquestdb-rs/src/token_provider.rsquestdb-rs/tests/oidc_device_flow.rs
|
Also addressed the remaining polling nit in be839e4: the first token request is now immediate, retries wait using the adjusted interval, and the slow-down test asserts a single 10-second retry wait. |
- Waiting detach_events/detach_diagnostics wait only for this auth's own callback. They waited on the gate shared by every auth built from one builder, so detaching A blocked on sibling B's callback -- and deadlocked when B's callback waited for the detaching thread -- contrary to oidc.h. - A transient failure of the confirmation-only .well-known fetch is reported as the retryable Network error it is. It used to be swallowed and the issuer pin then failed the build with a terminal Config error, telling Entra/Google users to reconfigure during an IdP blip. - A token-store lock whose owner stamp names a dead process on this host is reclaimed after 10s instead of the 600s stale window, so a process killed mid-refresh no longer locks every successor out of token() and sign_in() for ten minutes. Stamps now use gethostname rather than the usually unexported $HOSTNAME. - Document that questdb_db_borrow_sender_with_retry retries an all-replica role reject until its budget, and pin it with a test. - Rustdoc: provider methods now state that OIDC Cancelled/Config errors stay terminal; OidcDeviceAuth::clear() states it clears nothing during a concurrent interactive sign_in().
Rust 1.98's clippy adds chunks_exact_to_as_chunks, which rejects chunks_exact/chunks_exact_mut with a constant chunk size. The FSB offset writer and its test both used a literal 4, failing the "cargo fmt and clippy" CI job under -D warnings.
`operation_wait_can_be_aborted_after_it_has_started` published its check count before reading the abort flag, so the main thread could set the flag in the window between the two: the first predicate call then aborted the wait on its own, and the >= 2 re-check assertion failed. The loaded value is now read before the counter is published, which the main thread's ordering already guarantees to be false. Verified by widening that window with a sleep -- the old ordering then fails every run, the new one passes.
- Release a token() already waiting behind a peer refresh as soon as a callback becomes active, as sign_in and clear already did. A diagnostic callback that waited for that thread stalled both for 6 x timeout. - Classify a token pull on a reusable-builder sibling from inside the shared-target callback as re-entry (fail fast), not as busy on another thread, which spent a transport's whole retry budget. - Let the teardown wait of clear() on a closed provider honour the callback abort check, so a callback that closes and then joins the clearing thread cannot hang both forever. - ILP/HTTP: replay the rotated credential once even when an in-loop 401 arrives after the retry deadline, and re-resolve a retryable provider failure after a 401 within the remaining retry budget. Correct comments that said the C binding clears the buffer on a failed flush. - Egress: keep RoleMismatch when the final failover round fails on role and the wall-clock deadline expires in the same round, as before. - Token store: a save that fails only after publishing its entry is reported as published, so the refresh token is remembered as persisted and cannot be replayed after a peer consumes it; clear always fsyncs the directory, so a retried clear cannot report an undurable delete as Ok. - Reject every token-store and CA-bundle path starting with `~` (including `~user/...`), as the headers document.
- The reader no longer re-codes a final round's HandshakeError or TlsError as SocketError when the failover deadline expires during that round. Static-auth readers saw the code change; RoleMismatch was already exempt for the same reason. Regression tests at unit and mock-server level. - CI runs the questdb-rs tests with `ffi-support`. The owned pool-borrow API, its retry loop and their tests are gated on it, and no CI job enabled it, so e.g. the all-replica SF pool retry had no running test. - New test for a token-provider failure on a real background reconnect with a sent, unacked frame queued. The existing test only covered the synchronous initial connect; its comment is corrected. - A provider InvalidApiCall reaches transports as a terminal ConfigError: fix the stale comments claiming it is preserved, and drop the unreachable InvalidApiCall arm from `prefer_over_trigger`.
- sign_in() against a token store that can never be used -- a
directory it may not create or write, a read-only filesystem, a path
that is not a directory -- now fails as Config before any device code
is shown. It failed as retryable Network ("Retry later") on every
call, so retry loops keyed on the code never stopped. token() keeps
the retryable classification.
- A per-identity lock left by an earlier process with this process's
pid (a container restarted with its entrypoint as PID 1) is reclaimed
after the 10 s dead-holder grace instead of the full stale window. A
stamp is attributed to this process only if it postdates the first
stamp this process could have written.
- ILP/HTTP: after a first-attempt 401, re-resolving the provider and the
request retries after the rotated attempt share one retry_timeout
deadline. They used two, so a flush could block for about twice
retry_timeout.
- Egress reader: a reconnect round the failover deadline cut off before
it dialled no longer replaces the RoleMismatch an earlier round
recorded, and the cut-off is reported as the deadline rather than as
a transport "shutting down".
- QWP/WS orphan drainers that had not opened a session kept polling a
closed or misconfigured OIDC provider and rewriting .last_error until
the sender closed. They now retire the slot for the session, leaving
it on disk, as the drive phase already does, and as oidc.h promises.
- OIDC HTTPS and the ILP/HTTP sender strip the brackets of an IPv6
literal host before building the rustls server name. Every HTTPS
request to such a host failed before the handshake.
- Token-endpoint responses are read into zeroizing buffers sized from
Content-Length, so buffers outgrown while reading are wiped too.
…atch tests - test_post_401_provider_retries_and_request_retries_share_one_deadline: time the flush from the 401 (where the shared deadline starts) instead of from before the first connect, which costs ~2s on Windows because localhost tries ::1 before the IPv4-only mock listener. - provider_reader_keeps_role_mismatch_when_deadline_cuts_off_a_round: raise failover_max_duration_ms from 100 to 500 (backoff 50 -> 10) so a slow CI agent cannot cut off the first reconnect round before it records a RoleMismatch.
- Read the OIDC callback and store-lock thread-locals with try_with, so close and detach from an atexit handler or static destructor no longer abort the process once the thread's thread-locals are destroyed. - Keep the original trigger when egress failover gives up on the wall-clock deadline, unless the last walk error is preferred (RoleMismatch, HandshakeError, TlsError, OIDC detail); no more "<no error captured>" or ProtocolError re-coded as SocketError. - Map a post-401 token re-fetch cut off by the failover deadline to the walk-deadline cutoff, keeping an earlier RoleMismatch. - Record a published-but-not-durable refresh token before the persistence diagnostic runs, so close() from it leaves none in memory. - Let the binding's acquire abort also release a wait for the token store's per-identity lock. - Do not report a provider fetch cancelled by the sender's own shutdown as CredentialUnavailable. - Retire an orphan slot for the session when its provider returns InvalidApiCall, as the foreground reconnect loop already stops. - Document the C++ busy/re-entry exception type, the default OIDC trust roots, the file store's tightening and sweep of an existing directory, the pool inbox-cap error code, and the connection-listener cap in the FFI rustdoc.
- Build: `is_provider_shutdown_error` was gated on `_egress`, but the QWP/WS sender calls it, so `--no-default-features --features sync-sender` (and any sender-only cut) failed with E0425. Gate it on the sender too, and silence the resulting dead-code warning for `Error::reclassified` in builds with no transport that re-wraps errors. - Token store: re-entry into a held identity lock was tracked in a thread-local. After the thread's thread-locals were destroyed (a C atexit handler, a static or thread-local destructor) the outer hold was not recorded, so the nested lock in `clear()` waited out the 3 s budget on the process mutex it already held and failed, leaving the credential on disk. Track held locks in a process-wide registry keyed by OS thread id instead. - Diagnostics: a persistence diagnostic queued behind a sibling auth's diagnostic callback waited without bound, so a callback that hands work to that thread and waits for it deadlocked both until a third thread closed the auth. The admission wait is now bounded (5 s) and the best-effort diagnostic is dropped; documented in oidc.h / oidc.hpp. - Fork: the error and docs said a forked child is refused once OIDC was "used" in the parent; constructing a provider is enough.
Level-3 tandem review — c-questdb-client #183 × py-questdb-client #133Reviewed native Critical / ModerateNone confirmed. No diff-created runtime regression or broken unchanged caller was confirmed in the inspected paths. Minor (in-diff, native #183)
DowngradedAn exploratory Python Validation and verdict
|
Summary
mainchanges and adapt the OIDC integration to the renamed QWP protocol and reader featuresCancellation and lifecycle semantics
questdb_oidc_auth_close(andOidcDeviceAuth::close) is safe from anythread, including this auth's own event callback and while a callback runs
on another thread. Publishing the close does not wait for the authentication
critical section, though it may briefly contend with wait registration; only
the drain — which waits for the acquisition critical section a callback runs
inside — is skipped while this auth's callback is active. It is never rejected
as callback re-entry, unlike
sign_inandclear.token()serves a validcached token, including from a renderer callback, and is rejected only when it
would have to start a fresh acquisition. The header contract is updated
accordingly.
OidcDeviceAuth::signal_closeexposes that publish-without-drain step forcallers that cannot wait for authentication work to drain.
clearoutlivesclose: closing drops the in-memory credential but leavesthe persisted entry, so clearing has to keep working or the credential cannot
be removed at all.
reclassified as a retryable
SocketError, which made an attached senderreconnect indefinitely without ever reporting the close.
no longer discards it. Previously the credential was destroyed on disk and in
memory, locking a headless client out after its first silent refresh.
Retry-Afterraises the device-poll interval and never lowers it below theadvertised one (RFC 8628 §3.5), and a long value is no longer truncated to
60s.
field — rejected rather than truncated, since a cut URL can still parse as a
valid different URL.
Testing
cargo fmt --all -- --checkcargo clippy --all-targets --features almost-all-features -- -D warningscargo check --all-targets --features almost-all-featurescargo check --all-targets --no-default-features --features oidc,tls-webpki-certs,ring-cryptocargo test --features almost-all-features --test oidc_device_flowcargo test --features almost-all-features oidccargo test --features almost-all-features token_providerBreaking changes
Five, and none of them is the OIDC surface. The whole of
questdb-rs/src/oidc/,include/questdb/oidc.handinclude/questdb/oidc.hppis new in this PR — none of it exists at the merge base — so no decision taken
about its API can break an existing user, however much it changed while the
branch was being built. The five below all land on API that does exist at
the merge base.
ReaderConfig::upgrade_headersreturnsResultinstead of a bareVec. Resolving a rotating token provider can fail, so the method becamefallible.
ReaderConfigis on the crate's semver-committed export list(
egress/mod.rs: "Adding to this list commits the crate to a semvercontract"), so this is a source-breaking change for any Rust caller: add
?,or
.unwrap()on a static-credential config, which never returnsErr. Therustdoc carries the same note. C, C++ and Python are unaffected — the method
has no FFI export.
Callback inbox capacities are now capped at 65536. A
connection_listener(RustSenderBuilder, Cline_sender_opts_connection_event_handler), the pool's rejection inbox,and the config-string key
error_inbox_capacitypreviously accepted anycapacity and passed it to
VecDeque::with_capacity; a value aboveMAX_CONNECTION_EVENT_INBOX_CAPACITYnow fails instead of reserving thememory: with
config_errorfromline_sender_opts_connection_event_handler,the Rust
SenderBuilder/pool, and the config key, and withinvalid_api_callfrom the C pool entry points (questdb_db_connect_ex,questdb_db_connect_with_event_handler,questdb_db_connect_with_handlers),which validate the capacity before any config parsing (
client.hstatesthis). This includes strings such as
ws::addr=h:9000;error_inbox_capacity=200000;, which previously built andnow fail during config parsing. Code that passed a larger capacity and worked
will now fail at build/connect time. The cap exists because the FFI crate
aborts on allocation failure, so an unvalidated capacity from C was an abort
primitive.
Config strings are now capped at 1 MiB (
QUESTDB_CONFIG_MAX_BYTES).questdb_db_connect*and the other conf entry points that take acaller-supplied string reject a longer one rather than parsing it
(
questdb-rs-ffi/src/lib.rs:865validated_config_str, applied atlib.rs:2617,lib.rs:3501,egress.rs:481,egress.rs:511). The cap isdeliberately scoped to strings crossing the ABI: the
*_from_envfamily(
line_sender_opts_from_env,line_sender_from_env,qwp_reader_from_env)reads a locally-set environment variable and stays uncapped. Only a
pathological caller is affected, but a previously-accepted input now fails.
A durable-ACK store-and-forward pool retries an all-replica role reject.
questdb_db_borrow_sender_with_retry/borrow_sender_owned_with_retryon apool with
request_durable_ack=onwhose endpoints all reject by role (421 +X-QuestDB-Role: REPLICA) used to fail on the first attempt withProtocolVersionError: the pool re-wrap dropped the role-reject payloadthat marks the error retryable.
db.rsnow keeps it (reclassified), so thecall retries until
budget_msexpires -- or succeeds once a primary iselected -- and still ends with
ProtocolVersionError. This matches thedirect-sender classification;
qwp_sender.hdocuments it.Query failover that runs out of time reports what it found. When
failover_max_duration_msexpires while reconnect attempts remain and thelast reconnect round was rejected on role (
RoleMismatch), at the WebSocketupgrade (
HandshakeError) or at TLS (TlsError),next_batchnow returnsthat error, its message prefixed with the wall-clock budget context, where
it previously returned the transport failure that started the failover. This
matches a failover that runs out of attempts. In every other case the
original trigger is still returned unchanged, including a query that ran
past the budget before its first failure.
Not a breaking change, although an earlier revision of this description
listed it as one: reclassification now keeps the whole error payload
(
questdb-rs/src/error.rs:411reclassified), includingError::in_doubt,where the re-wrap sites in
qwp_ws_driver.rs,egress/reader.rsanddb.rspreviously built a fresh
Error. No call sequence that exists at the mergebase can observe the difference: the only in-crate producers of
in_doubt = true(FlushFailure::into_errorand the ILP/HTTP provider-after-401path) run after, or outside, every re-wrap site. The flag can reach a re-wrap
site only through the new Rust
*_token_providerAPI, when the provider's ownerror is marked in doubt; C, C++ and Python callers cannot produce that.
This repository squash-merges, so per-commit
!markers andBREAKING CHANGE:trailers do not survive. The PR title therefore carries!: it becomes the landing subject and is the durable breaking-change signal.Design decisions worth flagging
Not breaking changes — both concern API introduced by this PR — but both
reverse an earlier draft, so anyone who read this branch before will want them.
sign_in()prompts in a process with no TTY.is_interactive()initially defaulted to
stderr().is_terminal(), which refuses on theabsence of evidence rather than evidence of absence: a human at a real
terminal behind
prog 2>&1 | tee log, a process supervisor, or an IDE runconfiguration has no TTY, and all were turned away from a sign-in that works.
A cron job or CI step that wants to fail fast must pass
interactive(false)explicitly, or it will poll until the device code expires. A binding with a
stronger signal can supply one; the Python client passes
falsewhen aJupyter kernel reports
allow_stdin=False.questdb::oidc::device_authis not copyable. An earlier draft gave it acopy constructor calling
questdb_oidc_auth_clone, which does not duplicatethe provider — it takes another handle on ONE shared state, so
auto b = a; b.clear();deleted the credentiala's transports were using,including the on-disk refresh token. The constructor is now deleted: use
auto b = a.share();, or passconst device_auth&. The C ABI is unchanged.