Skip to content

feat(oidc)!: C client adds OIDC device flow - #183

Open
glasstiger wants to merge 248 commits into
mainfrom
ia_oidc_device_flow
Open

glasstiger wants to merge 248 commits into
mainfrom
ia_oidc_device_flow

Conversation

@glasstiger

@glasstiger glasstiger commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add RFC 8628 OIDC device authorization with QuestDB discovery, secure user prompts, silent refresh, and cross-process token persistence
  • expose rotating Bearer-token providers for ILP/HTTP, QWP/WebSocket ingress, and the QWP/WebSocket reader
  • add an end-to-end example and comprehensive OIDC/token-provider coverage
  • merge the latest main changes and adapt the OIDC integration to the renamed QWP protocol and reader features

Cancellation and lifecycle semantics

  • questdb_oidc_auth_close (and OidcDeviceAuth::close) is safe from any
    thread, 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_in and clear. token() serves a valid
    cached 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.
  • New OidcDeviceAuth::signal_close exposes that publish-without-drain step for
    callers that cannot wait for authentication work to drain.
  • clear outlives close: closing drops the in-memory credential but leaves
    the persisted entry, so clearing has to keep working or the credential cannot
    be removed at all.
  • A permanently closed provider is terminal for reconnect classification. It was
    reclassified as a retryable SocketError, which made an attached sender
    reconnect indefinitely without ever reporting the close.
  • A refresh that rotates the refresh token but omits the configured token kind
    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-After raises the device-poll interval and never lowers it below the
    advertised one (RFC 8628 §3.5), and a long value is no longer truncated to
    60s.
  • The openable device URL is length-bounded like every other untrusted display
    field — rejected rather than truncated, since a cut URL can still parse as a
    valid different URL.

Testing

  • cargo fmt --all -- --check
  • cargo clippy --all-targets --features almost-all-features -- -D warnings
  • cargo check --all-targets --features almost-all-features
  • cargo check --all-targets --no-default-features --features oidc,tls-webpki-certs,ring-crypto
  • cargo test --features almost-all-features --test oidc_device_flow
  • cargo test --features almost-all-features oidc
  • cargo test --features almost-all-features token_provider

Breaking changes

Five, and none of them is the OIDC surface. The whole of
questdb-rs/src/oidc/, include/questdb/oidc.h and include/questdb/oidc.hpp
is 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_headers returns Result instead of a bare
    Vec.
    Resolving a rotating token provider can fail, so the method became
    fallible. ReaderConfig is on the crate's semver-committed export list
    (egress/mod.rs: "Adding to this list commits the crate to a semver
    contract"), so this is a source-breaking change for any Rust caller: add ?,
    or .unwrap() on a static-credential config, which never returns Err. The
    rustdoc 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 (Rust SenderBuilder, C
    line_sender_opts_connection_event_handler), the pool's rejection inbox,
    and the config-string key error_inbox_capacity previously accepted any
    capacity and passed it to VecDeque::with_capacity; a value above
    MAX_CONNECTION_EVENT_INBOX_CAPACITY now fails instead of reserving the
    memory: with config_error from line_sender_opts_connection_event_handler,
    the Rust SenderBuilder/pool, and the config key, and with
    invalid_api_call from 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.h states
    this). This includes strings such as
    ws::addr=h:9000;error_inbox_capacity=200000;, which previously built and
    now 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 a
    caller-supplied string reject a longer one rather than parsing it
    (questdb-rs-ffi/src/lib.rs:865 validated_config_str, applied at
    lib.rs:2617, lib.rs:3501, egress.rs:481, egress.rs:511). The cap is
    deliberately scoped to strings crossing the ABI: the *_from_env family
    (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_retry on a
    pool with request_durable_ack=on whose endpoints all reject by role (421 +
    X-QuestDB-Role: REPLICA) used to fail on the first attempt with
    ProtocolVersionError: the pool re-wrap dropped the role-reject payload
    that marks the error retryable. db.rs now keeps it (reclassified), so the
    call retries until budget_ms expires -- or succeeds once a primary is
    elected -- and still ends with ProtocolVersionError. This matches the
    direct-sender classification; qwp_sender.h documents it.

  • Query failover that runs out of time reports what it found. When
    failover_max_duration_ms expires while reconnect attempts remain and the
    last reconnect round was rejected on role (RoleMismatch), at the WebSocket
    upgrade (HandshakeError) or at TLS (TlsError), next_batch now returns
    that 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:411 reclassified), including Error::in_doubt,
where the re-wrap sites in qwp_ws_driver.rs, egress/reader.rs and db.rs
previously built a fresh Error. No call sequence that exists at the merge
base can observe the difference: the only in-crate producers of
in_doubt = true (FlushFailure::into_error and the ILP/HTTP provider-after-401
path) run after, or outside, every re-wrap site. The flag can reach a re-wrap
site only through the new Rust *_token_provider API, when the provider's own
error is marked in doubt; C, C++ and Python callers cannot produce that.

This repository squash-merges, so per-commit ! markers and
BREAKING 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 the
    absence 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 run
    configuration 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 false when a
    Jupyter kernel reports allow_stdin=False.
  • questdb::oidc::device_auth is not copyable. An earlier draft gave it a
    copy constructor calling questdb_oidc_auth_clone, which does not duplicate
    the provider — it takes another handle on ONE shared state, so
    auto b = a; b.clear(); deleted the credential a's transports were using,
    including the on-disk refresh token. The constructor is now deleted: use
    auto b = a.share();, or pass const device_auth&. The C ABI is unchanged.

glasstiger and others added 22 commits July 3, 2026 13:18
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
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The 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.

Changes

OIDC authentication and rotating token providers

Layer / File(s) Summary
Feature wiring and provider contracts
questdb-rs/Cargo.toml, questdb-rs/build.rs, questdb-rs/src/lib.rs, questdb-rs/src/token_provider.rs, questdb-rs/src/ingress/..., questdb-rs/src/egress/config.rs
Adds OIDC feature gates, TLS integration, public OIDC exports, and rotating Bearer-token providers.
Transport token resolution
questdb-rs/src/ingress/..., questdb-rs/src/egress/..., questdb-rs/tests/oidc_device_flow.rs
Resolves tokens before HTTP sends and on QWP/WebSocket connection rounds. Reuses HTTP tokens across retries and rejects conflicting credentials.
OIDC protocols and public models
questdb-rs/src/oidc/http.rs, questdb-rs/src/oidc/discovery.rs, questdb-rs/src/oidc/error.rs, questdb-rs/src/oidc/render.rs, questdb-rs/src/oidc/token.rs, questdb-rs/src/oidc/mod.rs
Adds secure HTTP operations, discovery, structured errors, device-flow rendering, browser validation, and token models.
Persistent token storage
questdb-rs/src/oidc/token_store.rs, questdb-rs/src/oidc/token_store/tests.rs
Adds canonical token identities, defensive loading, atomic persistence, permissions, cleanup, and cross-process locking.
Device authorization orchestration
questdb-rs/src/oidc/device.rs, questdb-rs/src/oidc/device/tests.rs, questdb-rs/examples/oidc_device_auth.rs
Adds configuration resolution, cached-token loading, refresh, interactive sign-in, polling, persistence, and token rotation.
C and C++ API integration
include/questdb/oidc.*, include/questdb/client.*, include/questdb/ingress/*, include/questdb/egress/*, questdb-rs-ffi/src/*, examples/oidc_*, cpp_test/test_oidc.cpp
Adds C and C++ OIDC builders, authentication handles, structured errors, sender/reader integration, pool options, examples, and ABI tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: bluestreak01, jerrinot

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding OIDC device-flow support to the C client. It is concise and directly related to the pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ia_oidc_device_flow

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
questdb-rs/src/oidc/device.rs (1)

929-942: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

The loop sleeps before the first poll, so every sign-in waits at least one interval.

interval is floored at MIN_POLL_INTERVAL (5s). The sleep runs before the first token request, so an instant user authorization still costs 5 seconds. The integration test in questdb-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_interval in questdb-rs/src/oidc/device/tests.rs, which indexes durations[0] and durations[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

📥 Commits

Reviewing files that changed from the base of the PR and between 9c3a96f and 232a95a.

📒 Files selected for processing (26)
  • questdb-rs/Cargo.toml
  • questdb-rs/build.rs
  • questdb-rs/examples/oidc_device_auth.rs
  • questdb-rs/src/egress/config.rs
  • questdb-rs/src/egress/transport.rs
  • questdb-rs/src/ingress.rs
  • questdb-rs/src/ingress/conf.rs
  • questdb-rs/src/ingress/mod.md
  • questdb-rs/src/ingress/sender.rs
  • questdb-rs/src/ingress/sender/http.rs
  • questdb-rs/src/ingress/sender/qwp_ws.rs
  • questdb-rs/src/ingress/tests.rs
  • questdb-rs/src/ingress/tls.rs
  • questdb-rs/src/lib.rs
  • questdb-rs/src/oidc/device.rs
  • questdb-rs/src/oidc/device/tests.rs
  • questdb-rs/src/oidc/discovery.rs
  • questdb-rs/src/oidc/error.rs
  • questdb-rs/src/oidc/http.rs
  • questdb-rs/src/oidc/mod.rs
  • questdb-rs/src/oidc/render.rs
  • questdb-rs/src/oidc/token.rs
  • questdb-rs/src/oidc/token_store.rs
  • questdb-rs/src/oidc/token_store/tests.rs
  • questdb-rs/src/token_provider.rs
  • questdb-rs/tests/oidc_device_flow.rs

Comment thread questdb-rs/src/egress/config.rs
Comment thread questdb-rs/src/egress/config.rs Outdated
Comment thread questdb-rs/src/ingress.rs Outdated
Comment thread questdb-rs/src/ingress/mod.md Outdated
Comment thread questdb-rs/src/oidc/device.rs Outdated
Comment thread questdb-rs/src/oidc/device.rs
Comment thread questdb-rs/src/oidc/token_store/tests.rs
@glasstiger

Copy link
Copy Markdown
Contributor Author

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.
@glasstiger

Copy link
Copy Markdown
Contributor Author

Level-3 tandem review — c-questdb-client #183 × py-questdb-client #133

Reviewed native 90a52e32e02187b6c2d5c624587c705ae98cc585 against Python 689e95cd6919f3979de28e556ed67d532a8fee2d (Python pins this exact native commit). Ten independent read-only review angles, a widened unchanged-caller pass, and source verification covered the native OIDC/C ABI, Python bindings, token providers, transport retry and DataFrame/Arrow paths. This is a review of those heads, not of later commits.

Critical / Moderate

None confirmed. No diff-created runtime regression or broken unchanged caller was confirmed in the inspected paths.

Minor (in-diff, native #183)

questdb-rs/src/oidc/token_store.rs:33–47 says non-Unix file persistence falls back to directory ACLs, suggesting a write path is supported. Production preflight_store, save, and clear instead reject mutation on non-Unix targets (:1401–1415, :1511–1521, :1604, :1673). Reading an existing credential is supported, but persisting/clearing one there is not. Please align the module-level introduction with the accurate class documentation at :694–709. This is a documentation inconsistency, not a confirmed security or persistence implementation defect.

Downgraded

An exploratory Python pytest test/test.py run failed 86 times because pytest collects the abstract TestBases mixins; those are not PR regressions. The project's intended unittest runner passed.

Validation and verdict

  • Built Python at these heads; aggregate unittest: 1,155 run, 41 skipped. Focused auth/capsule/DataFrame-failure pytest run: 320 passed, 16 skipped.
  • Native OIDC core tests: 317 passed; full native FFI suite: 187 passed; native device-flow mock integration: 13 passed. Both diffs pass git diff --check. Live human/IdP and real-server tests were not performed here; CI must complete separately.
  • Approve with a minor documentation correction (1 confirmed in-diff finding, 0 out-of-diff regressions, 0 draft findings dismissed). Merge sequence remains Python #140 first, then this PR, then repin Python #133's gitlink to this PR's squash commit before merging it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant