Skip to content

feat: OIDC device-flow authentication - #133

Open
glasstiger wants to merge 104 commits into
mainfrom
ia_oidc_device_flow
Open

feat: OIDC device-flow authentication#133
glasstiger wants to merge 104 commits into
mainfrom
ia_oidc_device_flow

Conversation

@glasstiger

@glasstiger glasstiger commented Jun 15, 2026

Copy link
Copy Markdown

Summary

New questdb.auth module that lets you sign in interactively to OIDC-secured
QuestDB Enterprise
from Python — including from remote kernels (JupyterHub,
SageMaker, Colab, VS Code-remote, containers) that have no local browser.

It runs the OAuth 2.0 Device Authorization Grant (RFC 8628)
entirely client-side: you authorize in any browser (laptop or phone), while the
kernel only makes outbound calls to your identity provider (IdP). The resulting
token is presented to QuestDB over the auth paths it already supports — HTTP
Authorization: Bearer or PG-wire _sso — so no server change is required.

Pure Python, built on the standard library (urllib); questdb.ingress is
never imported at module load, and all optional deps are lazy.

Two ways to use it

Just the token — no extra dependencies; works with PG-wire, raw HTTP, the
ingestion Sender, or any client:

from questdb.auth import OidcDeviceAuth

auth = OidcDeviceAuth.from_questdb("https://questdb.example.com:9000")
token = auth.token()        # device flow on first use, else cached / refreshed
headers = auth.headers()    # {"Authorization": "Bearer <token>"}

On first use you get a sign-in prompt (clickable link in Jupyter, plain text on
a terminal). Re-running is silent: the token is cached and refreshed silently as
it nears expiry.

PG-wire adapters — feed the auto-refreshed token in as the _sso password
(require acl.oidc.pg.token.as.password.enabled=true on the server):

from questdb.auth import OidcDeviceAuth, sqlalchemy_engine, psycopg_connect

url = "https://questdb.example.com:9000"
auth = OidcDeviceAuth.from_questdb(url)
auth.token()                          # sign in once up front, before the pool opens

engine = sqlalchemy_engine(auth, url) # fresh token injected on every new connection
conn = psycopg_connect(auth, url)     # raw psycopg / psycopg2 connection

For REST (Authorization: Bearer) and ingestion (the ILP Sender), there is no
dedicated adapter — take auth.headers() / auth.token() and wire it in
yourself, e.g. Sender.from_conf("https::addr=…;", token=auth.token()).

Highlights

  • Config auto-discovery from the QuestDB /settings endpoint (acl.oidc.*),
    falling back to the IdP .well-known/openid-configuration. Anything passed
    explicitly overrides discovery; discovery can also be skipped entirely.
  • Correct token selection mirroring QuestDB's own logic
    (groupsEncodedInToken ? id_token : access_token), requesting the openid
    scope automatically when the id_token is sent. A completed grant that is
    missing the required token kind fails once with a clear error rather than
    caching an unusable token.
  • Token lifecycle: in-process cache with silent refresh via the
    refresh_token; re-prompts only when the refresh token is missing/rejected.
    A transient failure — a network blip or an IdP 5xx/429, on a poll or a
    refresh — is surfaced and retried, not re-prompted, while a terminal IdP
    rejection fails fast with a clear error. A lock serializes refresh so parallel
    cells/threads don't double-prompt. The cache is an always-on, process-global
    in-memory store
    : re-running a notebook cell reuses the token, a kernel
    restart re-prompts once. By default nothing is written to disk; pass
    token_store=FileTokenStore.at_default_location() to opt into persistence
    so a restarted process resumes from a saved refresh token — an owner-only
    (0600) plaintext file per identity under ~/.questdb/oidc-tokens/ — instead
    of re-prompting.
  • PG-wire connection adapters: SQLAlchemy and psycopg / psycopg2, injecting
    the auto-refreshed token as the _sso password (sqlalchemy_engine
    re-supplies a fresh token on every new pooled connection). That per-connection
    injection is non-interactive — it reuses / silently refreshes the cached
    token but refuses to start the device flow from a pool thread (raising
    OidcInteractionRequired rather than blocking the pool on a browser prompt),
    so you sign in once up front. REST and the ingestion Sender are
    bring-your-own-client via headers() / token().
  • Non-interactive detection: raises OidcInteractionRequired instead of
    hanging under papermill / cron / CI.
  • Jupyter-first prompt (clickable link, optional QR code via qrcode) with a
    plain-text terminal fallback that degrades gracefully on a non-UTF-8 console
    (the verification URL and code always print, even if the emoji can't be
    encoded — it no longer silently swallows the whole prompt). A device-
    authorization response missing the verification URI is rejected rather than
    rendering a blank prompt, and the "expires in N min" message reflects the
    token's real lifetime (its JWT exp), not the cache's re-validation clamp.
  • Typed errors: every failure path raises an OidcError subclass
    (OidcConfigError, OidcNetworkError, OidcInteractionRequired,
    OidcDeviceFlowError, OidcTimeoutError) — malformed config, HTTP/URL errors
    and bad server payloads are mapped to these rather than leaking raw
    ValueError / AttributeError / http.client exceptions. Even a hostile
    non-string error / error_description in an IdP response is coerced, so it
    can't escape the contract as a raw TypeError.

Security

  • No IdP passwords are entered in the notebook; MFA/SSO happen at the IdP.
  • https is required; plaintext is allowed only to a loopback address.
    insecure=True permits plaintext to a non-loopback QuestDB host only — it
    never downgrades the IdP (so the device code and refresh token are never
    sent in cleartext) and never disables certificate verification.
  • The credential (device-authorization / token) endpoints must share one
    origin, and HTTP redirects are refused
    — urllib doesn't strip the
    Authorization header across a cross-origin 30x, and the origin check only
    sees the pre-redirect URL, so a silently-followed redirect could leak the token
    or refresh token. A 30x on those endpoints surfaces as an error instead (and
    redirect-refusal is pinned on the HTTPS opener, the production path that
    carries the bearer / refresh token).
  • Out-of-band IdP pin — pass issuer= / discovery_url= to pin the IdP. The
    discovery origin is never derived from a server-supplied token endpoint, so a
    tampered /settings can't redirect the device-code / refresh-token POSTs. The
    pin is required before trusting credential endpoints that arrive over an
    untrusted plaintext /settings (only reachable with insecure=True), and
    before discovering endpoints from the IdP. On a path-based multi-tenant IdP
    (e.g. Keycloak …/realms/{realm}) it also scopes endpoints to the issuer
    path, rejecting .. traversal even when hidden by percent-/double-encoding,
    a backslash, or a matrix ;param.
  • Only server-authoritative /settings config is trusted — discovery reads
    the nested config object and ignores the user-writable preferences sibling
    (which the web console persists via PUT /settings), so a user who can write a
    preference can't smuggle an acl.oidc.* override (e.g. a redirected token
    endpoint) into the resolved config.
  • Hardened device-flow poll — the IdP-supplied expires_in / interval are
    clamped (sane lifetime cap, interval in [5s, 60s], never sleeps past the
    deadline), so a hostile or buggy IdP can't time the flow out before the first
    poll or stall the loop while it holds the acquisition lock. Transient poll
    failures (network, IdP 5xx/429, or a non-JSON proxy error) keep polling to
    the deadline; a terminal rejection — including a non-JSON 4xx from a WAF or
    proxy — stops immediately rather than polling on to a misleading timeout. A
    plain 429/5xx honours the IdP's Retry-After when present — even on a
    non-JSON body from a proxy/WAF — else the RFC 8628 +5s step, both clamped to
    the poll-interval bounds; a slow_down only ever increases the interval, so
    it never polls faster right after the IdP asked it to slow down (RFC 8628 §3.5).
  • The sign-in prompt can't be spoofed — C0/C1 control characters (incl. ESC)
    and bidi/zero-width characters are stripped from untrusted device-response
    fields (verification_uri, user_code, IdP error_description, JWT-derived
    identity) before they reach the TTY or notebook, so a MITM'd response can't
    inject ANSI escapes or reorder a URL's host. The verification link is shown
    with its host in IDNA/punycode form, and is only made clickable / opened in
    a browser / encoded as a QR when that host is a plain ASCII name — so a
    homoglyph dot (a fullwidth that IDNA folds to a real ., resolving to a
    different registrable domain) or an embedded user@host can't masquerade as
    the trusted host, and the link shown, the URL opened, and the QR all use one
    vetted target so they can't diverge. The Jupyter renderer additionally
    HTML-escapes its output and scheme-vets the link (no javascript:/data:).
  • Credentials kept out of logsTokenSet is immutable (frozen) and keeps
    the access/id/refresh tokens (and the sub) out of its repr, so a token
    can't leak into a log line or traceback. The PG adapters avoid logging the
    token / DSN and validate the PG-wire host and port: a host carrying
    connection-string metacharacters (; / = / whitespace / control / an IPv6
    zone-id %) or a non-integer port is rejected with a typed error, so a
    tampered URL can't inject libpq conninfo parameters.
  • Honours HTTPS_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, and ca_bundle=.

Dependencies & Python support

token() / headers() need nothing beyond the standard library. sqlalchemy,
psycopg / psycopg2, qrcode and IPython are imported lazily, only when
used. The declared minimum Python is corrected to 3.10 (setup.py >=3.8
>=3.10, docs/installation.rst >=3.9>=3.10), matching the floor
4.1.0 already adopted when it dropped Python 3.9; stale 3.8 references are
removed (RELEASING.rst, proj.py).

Docs, examples & tests

  • New guide docs/auth.rst (:ref:oidc_auth), API reference in docs/api.rst
    (OidcDeviceAuth, the two PG-wire adapters, OidcConfig, TokenSet, and the
    typed-error classes), index/installation updates, and a CHANGELOG.rst entry.
  • Runnable example examples/oidc_device_auth.py.
  • test/test_auth.py — pure-Python (no compiled extension required), wired into
    the suite via test/test.py. Covers the device flow, refresh, non-interactive
    contexts, discovery, the insecure-/settings guard, PG adapters (host/port
    validation), config helpers, endpoint validation, cache-key derivation, the
    HTTPS redirect-refusal path, multi-threaded and cross-instance cache-contention
    stress tests, and transport/renderer security (control/bidi/homoglyph/userinfo
    handling).

Also in this PR (CI & build robustness)

Bundled fixes to keep the matrix green, independent of the auth feature:

  • pandas 3 / numpy 2: derive the expected dtype in test_parquet_roundtrip
    instead of hardcoding object (pandas 3 reads back the new string dtype); keep
    32-bit wheel targets on the consistent pandas 2 / numpy 1 stack (pandas 3 ships
    no 32-bit wheels) so the dataframe tests actually run there.
  • QuestDB-master leg on JDK 25: build/run questdb master on JDK 25, build its
    -SNAPSHOT java client via the local-client Maven profile (new
    ci/templates/detect-local-client.yml), invoke Maven directly (the Maven task
    can't parse JDK 25), and pass the JPMS module-access flags the server needs.
  • Windows: silence mock-server tracebacks on abrupt client disconnect
    (narrowed to the specific broken-pipe / connection-reset / connection-aborted
    errors, not all ConnectionError), and skip the readonly AZP_ENHANCED* agent
    var in the wheel-build env re-export.
  • Updates the internal review-pr review skill (.claude/skills/review-pr/).

@coderabbitai

coderabbitai Bot commented Jun 15, 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

This PR introduces the questdb.auth package implementing client-side OAuth 2.0 Device Authorization Grant (RFC 8628) OIDC authentication for QuestDB Enterprise. The package includes a stdlib HTTP client, token cache backends, OIDC endpoint discovery, device-flow rendering for terminal and Jupyter, the core OidcDeviceAuth token manager, a QuestDB session with REST/SQLAlchemy/psycopg/ILP adapters, a full unit test suite, and comprehensive documentation. The minimum Python version is raised to 3.10. An unrelated Claude PR review skill document is also added.

Changes

questdb.auth OIDC Device Authorization Grant

Layer / File(s) Summary
Exception hierarchy and Python version bump
src/questdb/auth/_errors.py, setup.py, docs/installation.rst
Defines OidcError and seven typed subclasses; raises python_requires to >=3.10 in setup.py and updates the installation docs accordingly.
Stdlib HTTP client with TLS and security enforcement
src/questdb/auth/_http.py
Implements build_ssl_context, HttpResponse, loopback detection, HTTPS-only enforcement for non-loopback hosts, request(), get_json(), and post_form() using only Python stdlib.
TokenSet and cache backends
src/questdb/auth/_cache.py
Defines TokenSet with clock-skew-aware validity, TokenCache interface, and three implementations: MemoryCache (thread-safe), NullCache, and FileCache (atomic JSON writes, cross-process fcntl/msvcrt locking) plus make_cache() factory.
OIDC configuration discovery and endpoint validation
src/questdb/auth/_discovery.py
Implements OidcConfig, QuestDB /settings fetching/flattening, IdP .well-known fallback, relative endpoint resolution, same-origin and issuer-pin validation, and resolve_config() orchestrator.
Device-flow prompt rendering
src/questdb/auth/_render.py
Adds scheme-safe link validation, TerminalRenderer (plain-text + ASCII QR), JupyterRenderer (updatable HTML display + base64 PNG QR), environment detection, and make_renderer() factory.
OidcDeviceAuth token manager
src/questdb/auth/_device.py
Implements OidcDeviceAuth with from_questdb() classmethod, token lifecycle (cache gate → silent refresh → RFC 8628 device flow with poll loop), _tokenset_from_response, secure IdP transport, URL normalization, and browser-open safety.
QuestDB session and connection adapters
src/questdb/auth/_questdb.py
Adds QuestDB wrapper with sql() (REST /exec → pandas DataFrame), sqlalchemy_engine() (per-connection token injection), psycopg() (_sso auth), sender() (ILP bearer), and top-level connect().
Public API, example, changelog, and docs
src/questdb/auth/__init__.py, examples/oidc_device_auth.py, docs/auth.rst, docs/api.rst, docs/index.rst, CHANGELOG.rst
Wires __all__ for the public surface; adds a runnable example; adds comprehensive Sphinx documentation covering usage modes, discovery, caching, adapters, IdP requirements, and security; registers the auth toctree entry and updates the changelog.
Unit test suite, test infrastructure, and CI compatibility
test/test_auth.py, test/test.py, test/test_dataframe.py, test/mock_server.py, ci/pip_install_deps.py, ci/cibuildwheel.yaml
Adds 1167 lines covering device flow, non-interactive, refresh, file cache, discovery, REST adapter, concurrency, adapter mocks, config helpers, endpoint validation, cache-key normalization, transport security, and renderer security; registers all test classes; updates pandas dtype compatibility and pandas 3/32-bit wheel checks; makes HTTP test server quiet on abrupt connection closures; extends Windows env-var filtering.

Claude PR Review Skill

Layer / File(s) Summary
review-pr SKILL.md
.claude/skills/review-pr/SKILL.md
Defines a 4-step PR review workflow with 10 parallel agents, review level controls, change-surface mapping, verification requirements, comprehensive checklists (correctness, Cython/C-ABI, GIL, performance, tests), and structured output format for findings.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant connect as questdb.auth.connect()
  participant OidcDeviceAuth
  participant resolve_config
  participant QuestDB_Settings as QuestDB /settings
  participant IdP
  participant Renderer
  participant TokenCache
  participant QuestDB_Session as QuestDB session

  User->>connect: connect(url, eager=True)
  connect->>OidcDeviceAuth: from_questdb(url)
  OidcDeviceAuth->>resolve_config: url, overrides
  resolve_config->>QuestDB_Settings: GET /settings
  QuestDB_Settings-->>resolve_config: acl.oidc.* config
  resolve_config->>IdP: GET /.well-known/openid-configuration (if needed)
  IdP-->>resolve_config: device_authorization_endpoint
  resolve_config-->>OidcDeviceAuth: OidcConfig
  connect->>QuestDB_Session: QuestDB(url, auth)
  connect->>OidcDeviceAuth: token() [eager]
  OidcDeviceAuth->>TokenCache: load(cache_key)
  TokenCache-->>OidcDeviceAuth: miss
  OidcDeviceAuth->>IdP: POST device_authorization_endpoint
  IdP-->>OidcDeviceAuth: device_code, user_code
  OidcDeviceAuth->>Renderer: on_prompt(resp)
  Renderer-->>User: display verification URI + user code
  loop Poll IdP
    OidcDeviceAuth->>IdP: POST token_endpoint (device_code grant)
    IdP-->>OidcDeviceAuth: authorization_pending / tokens
  end
  OidcDeviceAuth->>TokenCache: store(cache_key, TokenSet)
  OidcDeviceAuth->>Renderer: on_success(identity, expires_in)
  connect-->>User: QuestDB session
  User->>QuestDB_Session: sql("SELECT ...") / sender() / sqlalchemy_engine()
Loading

🎯 4 (Complex) | ⏱️ ~75 minutes

🐇 Hoppity-hop through device flows and grants,
A token appears after suitable chants!
The IdP polls, the cache stores with care,
A Bearer in headers floats freshly through air.
Jupyter glows, the terminal prints neat—
OIDC for QuestDB, the login's complete! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the primary change: introduction of interactive OIDC device-flow authentication. It is concise, specific, and directly reflects the main feature addition across the changeset.
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.
✨ Finishing Touches
🧪 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.

@glasstiger glasstiger changed the title feat: OIDC device flow feat: interactive OIDC device-flow authentication (questdb.auth) Jun 15, 2026

@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: 6

🧹 Nitpick comments (1)
src/questdb/auth/_questdb.py (1)

82-94: 💤 Low value

Chain the import exception for better diagnostics.

When both psycopg and psycopg2 fail to import, the raised ImportError loses the underlying cause. Chaining the exception preserves the diagnostic context.

Suggested fix
 def _pg_module():
     try:
         import psycopg  # type: ignore  # psycopg v3
         return psycopg
     except ImportError:
         pass
     try:
         import psycopg2  # type: ignore
         return psycopg2
-    except ImportError:
+    except ImportError as e:
         raise ImportError(
             'A PostgreSQL driver is required: install `psycopg` (v3) or '
-            '`psycopg2-binary`.')
+            '`psycopg2-binary`.') from e
🤖 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 `@src/questdb/auth/_questdb.py` around lines 82 - 94, The second except
ImportError block in the _pg_module() function should preserve the exception
chain for better diagnostics. Capture the ImportError exception in the second
except block using `as e` syntax, then use `raise ... from e` syntax when
raising the new ImportError to chain the exception, which preserves the
underlying cause and provides better debugging context.

Source: Linters/SAST tools

🤖 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 @.claude/skills/review-pr/SKILL.md:
- Around line 157-162: The Step 3 preamble at lines 157-162 states that every
agent receives the full change-surface map, but Agent 10's input contract at
lines 231-240 explicitly limits it to only the PR diff and changed file names.
Add an explicit exception clause in the Step 3 preamble section to clarify that
Agent 10 does not receive the full change-surface map and instead receives only
the diff and changed file names, ensuring consistency between the general rule
and the specific Agent 10 behavior documented at lines 231-240.
- Around line 39-42: The table at lines 39-42 states that level 0 (default)
skips Step 2.5, but there is a later reference (around lines 144-155) that marks
Step 2.5e as mandatory at every level, creating a contradiction. Fix this
inconsistency by clarifying the level 0 description in the table: either modify
the "Skip Step 2.5" statement to specify that Step 2.5e is an exception and is
still performed, or restructure it to list exactly which substeps (2.5a, 2.5b,
2.5c, 2.5d, 2.5e) are executed or skipped. Ensure the description at level 0 and
the mandatory requirement stated at lines 144-155 are aligned so the default
path is no longer self-contradictory.

In `@docs/auth.rst`:
- Around line 65-76: The code example in the documentation is missing the import
statement for TimestampNanos, which is used in the sender.row() method call. Add
an import statement at the top of the code snippet to include TimestampNanos
from the questdb.ingress module (or appropriate module) so that the example can
be executed successfully when copied verbatim.

In `@src/questdb/auth/__init__.py`:
- Around line 75-92: The `__all__` list in the module needs to be sorted
alphabetically to comply with Ruff's RUF022 rule and maintain consistency in the
public API exports. Reorder all the items in the `__all__` list (which includes
'connect', 'QuestDB', 'OidcDeviceAuth', 'OidcConfig', 'TokenCache', 'TokenSet',
'MemoryCache', 'FileCache', 'NullCache', and the various OidcError types) in
alphabetical order while ensuring all items remain in the list.

In `@src/questdb/auth/_http.py`:
- Around line 123-129: The `_opener()` function doesn't receive the `insecure`
parameter, so it cannot enforce HTTPS-only redirect policies. To fix this, add
an `insecure` parameter to the `_opener()` function signature. When
`insecure=False`, create a custom redirect handler that validates redirects do
not downgrade from HTTPS to HTTP for non-loopback hosts (similar to the
validation in `_require_secure()`), and pass this handler to
`urllib.request.build_opener()` instead of using the default. When
`insecure=True`, use urllib's default redirect handling. Update all callers of
`_opener()` to pass the `insecure` parameter value.

In `@test/test_auth.py`:
- Around line 600-605: The timed joins with a 10-second timeout do not verify
that threads actually completed, which can cause non-daemon threads to remain
running and hang the test process if a regression causes a deadlock. After
calling t.join(10) on each thread in both the loop at lines 600-605 and at lines
828-835, add a verification check that confirms each thread has terminated by
asserting that t.is_alive() returns False, or raise an exception if any thread
is still alive after the timeout. This ensures the test fails cleanly rather
than leaving hanging threads.

---

Nitpick comments:
In `@src/questdb/auth/_questdb.py`:
- Around line 82-94: The second except ImportError block in the _pg_module()
function should preserve the exception chain for better diagnostics. Capture the
ImportError exception in the second except block using `as e` syntax, then use
`raise ... from e` syntax when raising the new ImportError to chain the
exception, which preserves the underlying cause and provides better debugging
context.
🪄 Autofix (Beta)

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

Run ID: bf4af383-9022-4ae5-b189-73691f4ccd00

📥 Commits

Reviewing files that changed from the base of the PR and between a523b3a and 4559587.

📒 Files selected for processing (18)
  • .claude/skills/review-pr/SKILL.md
  • CHANGELOG.rst
  • docs/api.rst
  • docs/auth.rst
  • docs/index.rst
  • docs/installation.rst
  • examples/oidc_device_auth.py
  • setup.py
  • src/questdb/auth/__init__.py
  • src/questdb/auth/_cache.py
  • src/questdb/auth/_device.py
  • src/questdb/auth/_discovery.py
  • src/questdb/auth/_errors.py
  • src/questdb/auth/_http.py
  • src/questdb/auth/_questdb.py
  • src/questdb/auth/_render.py
  • test/test.py
  • test/test_auth.py

Comment thread .claude/skills/review-pr/SKILL.md Outdated
Comment thread .claude/skills/review-pr/SKILL.md
Comment thread docs/auth.rst Outdated
Comment thread src/questdb/auth/__init__.py
Comment thread src/questdb/auth/_http.py Outdated
Comment thread test/test_auth.py Outdated
glasstiger and others added 26 commits June 15, 2026 22:18
The fastparquet -> pyarrow parquet roundtrip decays the categorical
column to a plain string column. On pandas >= 3 that reads back as the
new default string dtype (StringDtype(na_value=nan)) rather than object,
so the hardcoded np.dtype('O') in fallback_exp_dtypes no longer matched
and the assertion failed.

Derive the expected dtype from pd.Series(['x']).dtype instead of
hardcoding it: this is object on pandas < 3 and the new string dtype on
pandas >= 3, matching exactly what pyarrow's read_parquet produces, so
the test is version-agnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pandas 3 ships no 32-bit wheels. On win32 Python 3.11+ the pandas>=3
install was silently swallowed, fastparquet then pulled in a numpy-1-built
pandas 2.0.3 alongside numpy 2, and importing pandas crashed at runtime.
test.py tolerated the failed import and silently skipped every pandas test
(skip count 39 vs 32), while the 64-bit-only import sanity check never
fired to catch it.

Gate should_use_pandas3() on a 64-bit interpreter so 32-bit targets stay
on the consistent pandas 2 / numpy 1 stack and actually exercise the
dataframe tests again. 64-bit targets keep testing pandas 3 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mock HTTP server only caught BrokenPipeError, but on Windows an abrupt
client disconnect raises ConnectionAbortedError/ConnectionResetError --
siblings of BrokenPipeError under the common base ConnectionError, not
subclasses of it. The timeout, min-throughput, and retry tests disconnect
mid-request on purpose, so these slipped past the handler and the stdlib
dumped tracebacks to stderr. The tests still passed, but the CI logs looked
broken.

Broaden the handler except clauses to ConnectionError, and override
HTTPServer.handle_error to swallow connection errors that surface in the
stdlib keep-alive readline of the next request line -- outside any request
handler's try/except. Real errors are still reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The windows-2025 image injects a new readonly agent variable,
AZP_ENHANCED_WORKER_CRASH_HANDLING. The Windows "Build wheels" step
re-exports the vcvars environment via ##vso[task.setvariable ...], and
attempting to set this readonly var made the agent emit an ##[error],
marking the task failed even though all wheels built and tests passed.
Add AZP_ENHANCED to the exclusion regex (prefix match also covers any
future AZP_ENHANCED_* vars) in both windows_i686 and windows_x86_64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fixes on the OIDC device-flow auth module, plus a simplification
that removes the file cache entirely.

Hardening:
- device-flow poll gates success on _has_required_token (id_token in
  groups mode, else access_token) instead of always access_token: a
  completed grant missing the required kind now fails once with a clear
  error instead of caching an unusable token or discarding a usable
  id_token.
- QuestDB.sql() guards the 2xx path against non-JSON / non-dict bodies,
  raising OidcError instead of a raw JSONDecodeError / AttributeError.
- discovery requires an explicit issuer= (or discovery_url=) before the
  IdP .well-known fallback; the discovery origin is never derived from a
  server-supplied token endpoint, so a tampered /settings can't redirect
  the device-code / refresh-token POSTs.
- PG-wire / ILP adapters bracket IPv6 literals in the ILP addr= and raise
  a clear error on a host-less URL instead of passing None to the driver.
- validate_endpoint_origins and cache-key normalization raise
  OidcConfigError (not a bare ValueError) on a malformed port, via a
  shared safe_urlparse helper.
- the example imports questdb.ingress lazily, so it loads on the
  pure-Python path with no compiled extension.

Simplification:
- drop FileCache and its cross-process locking + at-rest refresh token;
  MemoryCache (process-global, survives notebook cell re-runs) is the only
  persistent backend, with NullCache for cache=None. This also removes the
  Windows msvcrt-lock no-op and corrupt-file edge cases entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…file

The linux-qdb-master job's "Compile QuestDB master" step failed because
questdb master depends on a -SNAPSHOT java-questdb-client that is not
published to Maven Central.

Add a detect-local-client step template (adapted from questdb/questdb's
ci/templates/detect-local-client.yml) that reads questdb.client.version
from the cloned core/pom.xml: for a -SNAPSHOT client it inits the
java-questdb-client submodule and builds it via the `local-client` Maven
profile; for a released client it resolves from Maven Central. The
"Compile QuestDB master" step appends the resulting $(CLIENT_PROFILE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QuestDB master bumped its build to Java 25 (javac.target=25). Its maven-enforcer requireJavaVersion reads ${java.enforce.version}, which is only set by the JDK-activated 'java25+' profile (<jdk>(24,)</jdk>). Building the linux-qdb-master leg with JDK 17 left that property empty, so the enforcer failed with 'JDK version can't be empty' before compilation.

Point the 'Compile QuestDB master' Maven task at $(JAVA_HOME_25_X64) via jdkVersionOption: path, and run 'Test vs master' on the same JDK 25 so the freshly compiled Java 25 bytecode can run. 'Test vs released' stays on JDK 17. Both ubuntu-latest and windows-2025 images preinstall Temurin 25 (JAVA_HOME_25_X64).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Maven@3 task crashes while parsing JDK 25 ('Cannot read properties of null (reading 'major')') — its JDK selection tops out at 21 and its Node-side version detector returns null for 25. Replace the task with a bash step that exports JAVA_HOME=$(JAVA_HOME_25_X64) and runs mvn directly, mirroring the task defaults (questdb/pom.xml, goal 'package', same -DskipTests -Pbuild-web-console $(CLIENT_PROFILE) options).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QuestDB master runs as the io.questdb JPMS module and now uses jdk.internal.vm.ContinuationScope, so on JDK 25 the server dies at startup with IllegalAccessError (java.base does not export jdk.internal.vm to io.questdb), plus Unsafe/native-access warnings. The test fixture launches questdb.jar directly rather than via questdb.sh, so the access flags questdb.sh normally supplies are absent.

Set JDK_JAVA_OPTIONS on the 'Test vs master' step with the exact module access flags from questdb.sh (all targeting io.questdb). Scoped to that step, so the JDK 17 'Test vs released' run is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When QuestDB is reached over plaintext http to a non-loopback host
(only possible with insecure=True), its /settings response is MITM-able.
The issuer-pin requirement previously fired only when an IdP endpoint was
missing (the discovery path). A tampered /settings advertising BOTH the
token and device-authorization endpoints at one attacker origin skipped
that path: the co-location check passed trivially (same origin) and the
issuer-pin check was vacuous (no issuer), so the device code and the
long-lived refresh token were POSTed to the attacker.

Require the same out-of-band pin (issuer= / discovery_url=) before
trusting /settings-supplied credential endpoints fetched over such an
untrusted channel. Endpoints the caller passed explicitly, and endpoints
from an authenticated (https / loopback) /settings, are unaffected, so
the https happy path and local-dev loopback are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The device-authorization response's expires_in / interval were trusted
verbatim, so a hostile or buggy IdP could break or stall the poll loop —
which runs under the acquisition lock, so a stall freezes every other
thread needing a token on that instance:

  * expires_in <= 0 set the deadline to "now", timing the flow out before
    its first poll even though the user could still authorize;
  * an unbounded interval (or repeated slow_down) produced a single
    enormous sleep() holding the lock.

Clamp both: expires_in <= 0 -> default, capped at a max lifetime; interval
to [1s, 60s] (including after slow_down); and never sleep past the
deadline. RFC-typical values (interval=5, expires_in=600) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several malformed-input paths escaped the package's typed-error contract
(callers catch OidcError) with a bare ValueError / AttributeError /
http.client.InvalidURL:

  * a non-string OIDC endpoint in /settings -> AttributeError from
    .startswith(); now treated as absent so resolution raises a clear
    OidcConfigError;
  * a /exec "columns" entry that isn't an object -> AttributeError from
    .get(); now raises OidcError;
  * a malformed port in the QuestDB URL -> bare ValueError when an adapter
    read .port; QuestDB now validates it at construction via safe_urlparse;
  * the same malformed port reaching the /settings or discovery fetch ->
    http.client.InvalidURL; request() now wraps InvalidURL as
    OidcConfigError and any other HTTPException as OidcNetworkError, so the
    single HTTP choke point never leaks a raw http.client exception.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plain-text terminal prompt wrote untrusted device-authorization
response fields — verification_uri, user_code, the IdP error_description
and the JWT-derived identity — verbatim to the TTY. A hostile or MITM'd
response could embed ANSI escape sequences (cursor moves, screen clears)
to spoof the sign-in prompt or hide the real verification URL.

Strip C0/C1 control characters (incl. ESC) from those untrusted strings in
format_prompt() and TerminalRenderer.on_success/on_failure before they
reach the stream. The Jupyter renderer already html-escapes its output,
and the QR path encodes the URL as image data rather than terminal text,
so neither needed changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* CHANGELOG: drop the stale "optional on-disk cache" claim (the FileCache
  backend was removed; tokens are never written to disk) and note the
  python_requires bump to 3.10.
* docs/auth.rst: import TimestampNanos in the integrated-session snippet so
  it runs as written.
* docs/api.rst: document TokenCache, TokenSet, MemoryCache and NullCache,
  which are exported in __all__ but were missing from the reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lock-free fast path in OidcDeviceAuth reads a published TokenSet
without holding a lock, which is only safe because its fields never change
after construction — an invariant previously kept by convention alone.
Mark TokenSet frozen so any future in-place mutation fails loudly instead
of introducing a torn read, and convert the one such mutation (the refresh
carry-forward in _refresh) to dataclasses.replace().

Also keep the access/id/refresh tokens out of repr() so a TokenSet that
lands in a log line or traceback cannot leak credentials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reorder the export list using Ruff's isort-style ordering (CamelCase
names first, natural-sorted, then lowercase 'connect' last). The public
API is unchanged; this only resolves the RUF022 lint warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The level table said level 0 skips all of Step 2.5, but Step 2.5e is
documented as running at every level — a self-contradictory default
path. Clarify that levels 0 and 1 skip Steps 2.5a-d while still running
Step 2.5e (build & binding profile), aligning the table with the
'runs at every level' rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Step 3 preamble and Step 2.5 stated that every Step 3 agent
receives the change-surface map and build/binding profile facts, but
Agent 10 (the fresh-context adversarial agent) is documented as
receiving only the diff and changed file names. Carve Agent 10 out of
all three general-rule statements (Steps 2.5, 2.5e, and the Step 3
input contract) so they no longer contradict Agent 10's own section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QuestDB /settings nests server-authoritative values under a top-level
"config" object alongside a user-writable "preferences" sibling (the web
console persists UI prefs there via PUT /settings). Discovery now reads only
"config" and refuses to fall back to the top level of a structured response,
so a user who can write a preference cannot smuggle an acl.oidc.* key (e.g. a
redirected token endpoint that points the device code / refresh token at an
attacker) into the resolved OIDC config. Genuinely flat legacy responses are
still tolerated at the top level.

Ports the trust model from the Java client (java-questdb-client#52).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QuestDB._require_host() passed the URL hostname unsanitized into the ILP
conf string sender() builds (addr=host:port;). urlparse keeps ';' and '='
in .hostname, so a crafted or tampered URL such as
"https://host;tls_verify=unsafe_off;x=" injected extra conf params —
silently disabling the sender's TLS certificate verification (and exposing
the bearer token to a MITM), or e.g. auto_flush=off for data loss.

Reject ';', '=', whitespace and control characters in the resolved host
(':' stays allowed for IPv6 literals) at the single chokepoint shared by
sender()/psycopg()/sqlalchemy_engine(). Add a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M1 — keep four untrusted-input failures within the OidcError contract
instead of leaking a raw stdlib exception:

* int(float('inf')) from a JSON Infinity (json.loads accepts it) raised
  OverflowError — not a ValueError — in the expires_in/interval parses
  (_device.py); add OverflowError to those handlers.
* urllib.parse.urlparse() itself raises ValueError on a malformed IPv6
  literal (e.g. "https://[::1") before .port is read. safe_urlparse now
  wraps the urlparse() call, and _require_secure routes through it, so a
  bad endpoint from /settings or discovery raises OidcConfigError.
* build_ssl_context() leaked FileNotFoundError/ssl.SSLError for a
  mistyped ca_bundle path (or env var); map to OidcConfigError.
* deeply-nested JSON makes json.loads raise RecursionError — not a
  ValueError; catch it in get_json/post_form and QuestDB.sql.

M2 — _strip_control now also strips Unicode bidi-override / zero-width /
line-separator ranges (U+200B-200F, U+2028-202E, U+2066-2069, U+FEFF),
not just C0/C1. U+202E (RIGHT-TO-LEFT OVERRIDE) in an untrusted device
response could otherwise reverse displayed text in the terminal prompt to
disguise the real sign-in host.

Add 8 regression tests (overflow expires_in/interval, malformed-IPv6 at
safe_urlparse/_require_secure/_normalize_url/validate_endpoint_origins/
constructor, bad CA bundle, deeply-nested JSON, bidi/zero-width stripping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M3 — thread a per-request `timeout` (default 30s, unchanged) through every
IdP call (device-code request, each poll, refresh) via _idp_post, and
through the /settings + discovery fetches in from_questdb. It bounds how
long a single network leg can hold the token-acquisition lock when the IdP
stalls, so an IdP outage no longer freezes other acquiring threads (e.g.
SQLAlchemy pool connections) for the urllib default per leg. Exposed on
OidcDeviceAuth.__init__ / from_questdb and forwarded by connect(**opts):
lower it for tighter lock-hold, raise it for a slow IdP. Total interactive
poll duration stays capped by _MAX_DEVICE_CODE_LIFETIME.

M4 — add the remaining edge / error-path tests:
* device-code / poll / refresh use the configured timeout (M3)
* connect(eager=False) defers sign-in until the first token use
* IdP .well-known 404 -> OidcError (discovery non-2xx path)
* token/device non-JSON 2xx and non-dict JSON -> OidcError; /settings and
  discovery non-2xx / non-JSON -> OidcError (only /exec had this before)
* make_cache resolves 'memory' / None / 'none' / a TokenCache and rejects
  an unknown spec with OidcConfigError
* QR helpers degrade to None when `qrcode` is absent or data is empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* cache_key now includes the token-kind mode (groups_in_token): two
  sessions differing ONLY in that mode no longer collide on one in-memory
  cache entry and repeatedly evict each other's token. _select gates the
  served kind so no wrong token was ever returned, but the collision caused
  avoidable refreshes / re-prompts. (_device.py)
* _resolve_endpoint returns None for a path-only endpoint ("/as/token")
  when acl.oidc.host is absent, so resolution fails with a clear config
  error (pin the IdP / pass the endpoint explicitly) — or recovers via IdP
  discovery when issuer= is pinned — instead of passing a scheme-less
  "/path" downstream that surfaced as a confusing "insecure/malformed
  URL". (_discovery.py)
* _pg_module chains the underlying ImportError (raise ... from e) so the
  traceback preserves the real cause. (_questdb.py)

Add 3 regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M1 (issuer pin path-scope) — the issuer pin now scopes /settings-advertised
credential endpoints to the issuer PATH (segment-aware), not just its
origin, so a tampered /settings can't steer the device code / refresh
token to a different realm on a path-based IdP (Keycloak issuers are
https://host/realms/{realm}). The origin check (validate_endpoint_origins)
stays universal; the path check lives in resolve_config and applies ONLY
to /settings-supplied endpoints — caller-explicit and IdP-discovered
endpoints are authoritative and not path-restricted, so IdPs that place
endpoints outside the issuer path (e.g. Azure AD) still work.

M2 (forward CA to Sender) — QuestDB.sender() now forwards the private CA
(explicit ca_bundle=, else REQUESTS_CA_BUNDLE / SSL_CERT_FILE, same
precedence as build_ssl_context) to the ILP Sender as tls_roots for an
https QuestDB, so REST queries and ILP ingestion trust the same roots. PEM
file only; caller can override via tls_roots=/tls_ca=. ca_bundle is now
stored on OidcDeviceAuth and read by QuestDB.

M4 (token-field race) — the lock-free fast path is now strictly READ-ONLY:
the cache->field promotion of self._tokens moved into the locked slow-path
re-check (and an expired token is still promoted there so _acquire can
refresh it). Every write to self._tokens is now serialized; the lock-free
read of the frozen TokenSet is kept.

M5 (default_interval) — from_questdb (and thus connect(**opts)) now accepts
and forwards default_interval; connect(default_interval=...) previously
raised TypeError.

(M3, malformed-IPv6 -> OidcConfigError, was already fixed in ae4c5ba.)

docs/auth.rst: issuer pin documented as origin+path with the Azure caveat;
sender documented to inherit the CA bundle. Add 6 regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- _decode_jwt_claims: catch RecursionError so a deeply-nested JWT payload
  from a hostile/buggy IdP degrades to no-identity instead of crashing
  token()/refresh with a raw exception (mirrors _http/_questdb guards).
- _endpoint_path_under_issuer: reject '.'/'..' path segments (decoding
  %2e first) so a tampered /settings can't steer the device code and
  refresh token to a different realm via a path the IdP normalizes after
  the prefix check passes.
- _render: coerce non-string verification_uri/_complete to str/None and
  harden _safe_link_url, so an untrusted device response can't crash the
  prompt renderer with a bare TypeError/AttributeError.
- OidcDeviceAuth docstring: document that a missing/expired-token caller
  blocks behind an in-progress interactive sign-in, and that connect(
  eager=True) (the default) is the mitigation.

Adds regression tests for each (full auth suite: 127 pass, 11 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
glasstiger and others added 17 commits June 26, 2026 13:17
Three more review fixes to questdb.auth:

- M5: sqlalchemy_engine's do_connect listener ran auth.token() on pool
  threads, so a first-time interactive sign-in could block the whole pool
  (or storm OidcInteractionRequired in a headless pool). Thread an internal
  allow_interactive flag: the pool callback now reuses / silently refreshes
  the cached token but refuses to START the device flow, raising a clear
  "sign in up front" error instead. Public token() is unchanged, and the
  silent refresh still runs on pool threads.

- M6: a slow_down poll response carrying a Retry-After lower than the
  current interval reset the interval to the floor -- polling FASTER right
  after the IdP told it to slow down (RFC 8628 section 3.5 requires an
  increase). slow_down now never drops below current + 5; a plain 429/5xx
  still honors Retry-After verbatim.

- M4: the concurrency stress test used a single instance, so the
  cross-instance generation/inflight CAS (the pool case) was never raced.
  Add a cross-instance stress test driving several instances that share one
  cache_key under real contention.

Full questdb.auth suite green (217 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remaining minor review fixes (the no-GIL CI gap is intentionally left out):

- Cross-instance cache: when an instance held a stale (non-None, expired)
  self._tokens while another instance sharing the process-global cache had
  stored a fresh valid token, the locked slow path reloaded the shared store
  only when self._tokens was None -- so the stale local token shadowed the
  fresher cached one and forced a redundant refresh. Reload and adopt a valid
  cached token whenever the local one is stale.

- Retry-After on non-JSON errors: post_form parsed Retry-After off the
  response headers but dropped it when the body was non-JSON, so a non-JSON
  429/503 from a proxy/WAF fell back to the fixed +5s step. Carry it on
  OidcError.retry_after and honor it in the poll loop's exception arm.

- _display_url: parts.port raises ValueError on a junk port, which aborted
  host normalization and showed a homoglyph host raw. Read the port
  defensively so the host is still IDNA/punycode-normalized (junk port
  dropped) -- the spoof is still revealed.

- Docs/infra: drop stale Python 3.8 references (RELEASING.rst, a dead proj.py
  comment); narrow the mock server's handle_error suppression to the specific
  client-disconnect errors (not all ConnectionError); group the exception
  autodoc by hierarchy and document OidcConfig's fields.

Adds regression tests for the first three (all teeth-checked by mutation).
Full questdb.auth suite green (221 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_display_url rebuilds an untrusted verification host in IDNA/punycode to
defeat homoglyph spoofing, but a confusable that NFKC-folds to a URL
delimiter (fullwidth solidus U+FF0F -> '/', U+FF20 -> '@', U+FF03 -> '#',
U+FF1F -> '?') makes urllib.parse.urlparse raise ValueError, and the
except branch returned the raw string. So a value like
"https://login.questdb.io<U+FF0F>@evil.example/device" was shown verbatim
in the terminal and Jupyter prompts -- reading as host login.questdb.io
while a browser resolves evil.example after the fold.

Confined to inert text (never clickable/opened/QR-encoded -- _safe_target
already rejected these), but that is exactly the manual copy/retype path
the IDNA-display defense exists to stop.

Route both fail-open branches (and the existing IDNA-failure branch)
through a shared _ascii_visible() that escapes non-ASCII to a visible
\uXXXX, so a confusable can't masquerade as a trusted host even when the
host can't be normalized. Add a TestRendererSecurity regression covering
U+FF0F/FF20/FF03/FF1F.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three minor findings from the device-flow review:

- Poll loop: a non-JSON 5xx from a proxy/WAF carrying a Retry-After header
  was ignored by the exception arm (only a 429 backed off), while the
  JSON-body path honored it. Mirror the JSON arm so a transient 5xx with a
  Retry-After backs off by that value; a 429 still backs off without a
  header, and a 5xx without one keeps its cadence.

- cache_key: the issuer was trailing-slash-normalized but the token
  endpoint was not, so ".../token" and ".../token/" produced different
  keys and forced an avoidable re-prompt. rstrip the normalized token
  endpoint too; the realm path still distinguishes multi-tenant keys.

- Renderer re-entrancy: the acquisition lock is held across the whole
  sign-in, including renderer callbacks, so a custom renderer calling back
  into the same instance's token()/clear() silently deadlocked for up to
  the device-code lifetime. Track the lock-owning thread and raise a typed
  OidcError on same-thread re-entry instead of hanging.

Adds a regression test for each.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
urllib.parse.urlparse() silently removes tab/newline/CR from the URL
before producing .netloc, but the transport (http.client via
Request.host) keeps them. So _reject_confusable_authority validated a
host that could diverge from the one urllib connects to — e.g.
"https://idp\t.good/token" parses with hostname "idp.good" while the
transport targets the raw "idp\t.good". Its _UNSAFE_AUTHORITY_RE lists
\s/\x00-\x1f but never saw these bytes because urlparse stripped them
first, leaving the guard's stated invariant to two incidental backstops
(the issuer/origin pin and http.client._validate_host).

Check the raw URL for these bytes up front so the dedicated guard
enforces its own contract. Add a regression test covering both the
label-merge and host-split cases across every construction path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
m1: _safe_link_url rejected only surrounding whitespace, but urlparse
silently removes tab/newline/CR from anywhere in the URL before parsing
— so it would vet the stripped form yet return (→ href / browser / QR)
the original with the bytes intact. Reject a URL carrying them so the
value vetted equals the value returned. Production already strips via
_safe_target; this closes the standalone footgun.

m2: an explicit host="[::1]" override reached the PG driver bracketed
(→ a confusing connection failure on a copy-pasted IPv6 literal), while
the URL-derived path is unbracketed by urlparse. Strip a surrounding
[...] so both paths hand the driver a bare address, matching the
docstring.

m3: post_form's return annotation said tuple[int, Dict] but it returns
_PostResult (a tuple subclass carrying .retry_after); correct it.

Add regression tests for m1 and m2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Point docstring :class: refs at the public questdb.auth.* targets that
  api.rst documents, instead of the private _device/_errors module paths
  (unresolved under the -nW nitpicky doc build).
- Add a test that a well-formed JWT whose payload decodes to non-object
  JSON (list/str/number) reads as no-claims and doesn't crash the
  success-path identity decode, guarding _decode_jwt_claims.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Isolate custom-renderer exceptions in the device flow: route
  on_prompt/on_waiting/on_failure through a best-effort _render_safe so a
  buggy renderer can't mask the authoritative OidcDeviceFlowError /
  OidcTimeoutError. An OidcError is re-raised, not swallowed, so the
  reentrancy guard's deadlock signal still reaches the caller; on_success
  stays inline-wrapped (it must never discard an already-authorized token).
- Reject '%' (IPv6 zone-id / percent-encoding) in a credential-endpoint
  authority, matching the host hygiene in _adapters._ILLEGAL_HOST_CHARS and
  _render._SAFE_HOST_RE that the comment already claimed to mirror; update
  the comment and error message to match.
- Correct the OidcConfig.scope docstring: the openid scope is added by
  OidcDeviceAuth in groups mode, not by the dataclass.
- Add regression tests for the renderer-masking and %-authority cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The response HEAD read (status line + headers) inside urllib's open()
had no wall-clock bound: urllib's timeout is per-socket-read and
http.client's begin() loops readline() over reads, so a peer dribbling
the status/header bytes one per timeout window kept open() blocked for
up to ~_MAXLINE * timeout per line. This pinned the thread holding the
acquisition lock and defeated the existing body-read watchdog, which is
armed only after open() returns.

Capture the connection socket as soon as it connects (a do_open-only
mixin that swaps in a socket-capturing connection class, leaving the
stdlib's version-specific TLS handling untouched) and arm a watchdog
that shuts it down at the deadline, released once the head read
finishes so it can't disturb the body read. A stalled head now surfaces
as a typed OidcNetworkError instead of hanging.

Add a real-socket regression test that dribbles the status line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M2 — the in-memory cache_key and the on-disk TokenStoreKey derived
identity by different rules, so they could disagree: the token-endpoint
query string was kept in memory but dropped on disk (two distinct
tenants could collide onto one store file and be served each other's
token), while scope order and a trailing slash were normalised in
memory but not on disk (splitting one identity across two files). Give
both a single normalisation: _canonical_endpoint now keeps the query and
strips a trailing slash, and the store key's scope is order-normalised
via the shared _normalize_scope, exactly as cache_key already does. For
the common case (no query, no trailing slash) the on-disk hash is
unchanged, so the cross-language file-sharing contract is preserved;
edge cases now agree and fail safe.

M3 — a token taken straight from the (untrusted) IdP token endpoint was
not screened for control/non-ASCII characters, unlike a token loaded
from the persistence file. A decoded CR/LF in the served token is an
Authorization-header / PG-wire _sso-password injection vector. Route the
wire-bound access/id tokens through the same _has_only_token_chars gate
the file path uses (new _safe_token_or_none); a screened-out token reads
as absent, so the grant fails terminally instead of routing a tampered
credential onto the wire.

M4 — add the two missing persistence tests: a coordinated refresh using
the newer in-memory token when a prior save left disk stale, and clear()
staying non-fatal when the store's lock backend (not clear() itself)
raises.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four minor hardening fixes:

- _seconds_to_millis checked finiteness BEFORE the *1000 scale, so a
  finite-but-huge value (e.g. 1e306) passed, overflowed to inf when
  scaled, and int(round(inf)) raised a raw OverflowError — escaping the
  store's OidcError contract via the public PersistedToken/save. Check
  finiteness after scaling.

- FileTokenStore accepted a non-finite lock_stale / lock_acquire_budget:
  inf passes the bare `> 0` / `> _MIN_LOCK_STALE` comparisons, and an
  infinite staleness window means a crashed holder's lock is never
  reclaimed. Reject non-finite (and a too-large int) up front.

- _strip_control kept variation selectors (U+FE00–FE0F, U+E0100–E01EF,
  category Mn) and enclosing combining marks (category Me), both
  invisible/overlay characters that reached a user_code / identity / URL
  path. Strip them, and cap a run of non-spacing marks (Mn) so a "Zalgo"
  stack can't smear over adjacent prompt lines; legitimate accents and a
  short combining run still render, and interleaved zero-width chars
  can't reset the cap.

- The direct OidcDeviceAuth(...) constructor validated only the two
  endpoints, not the issuer (resolve_config does on the from_questdb
  path), so a confusable or malformed issuer constructed fine and then
  raised lazily from cache_key on the first token() call. Validate the
  issuer authority in __init__ so it fails fast at construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-memory cache_key rstrip('/')-ed the whole rendered URL, while the
on-disk _canonical_endpoint strips the trailing slash on the path
component. That whole-string strip disagreed with the store on two
endpoint shapes: a slash before a query ('.../token/?x' stayed split
from '.../token?x'), and a slash that is part of a query value
('...?redirect=a/' wrongly collided with '...?redirect=a'). Either case
keyed one identity two ways in memory vs on disk.

Move the trailing-slash stripping into _normalize_url, applied to the
path component (mirroring _canonical_endpoint), and drop the now-redundant
cache_key rstrip. The two keys now make identical identity distinctions.

Also add the missing TLS-verification coverage: a unit assertion that
build_ssl_context() enforces CERT_REQUIRED + check_hostname (so a
regression swapping in an unverified context fails a test), plus a
behavioural handshake test that the default context rejects an untrusted
self-signed cert while a custom-CA context still verifies and connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address moderate findings from the PR #133 review:

- Persist and re-check `issuer` in the token store so a token pinned to
  one issuer is never served from disk to a session pinned to another.
  Kept out of the file-name hash, so the cross-language file contract and
  existing token files stay byte-stable; isolation is enforced via the
  on-load identity re-check, matching the in-memory cache_key.

- FileTokenStore.load() now returns None (not raises) for an unreadable
  entry: a directory/symlink-loop at the token path (stat.S_ISREG/errno
  guard) and a deeply-nested attacker file (RecursionError), restoring
  its documented contract and matching every other json.loads site.

- _display_url escapes a host that still carries a URL-structural char
  after IDNA (e.g. the U+FF3C/U+FE68 reverse-solidus fold) as a visible
  \uXXXX, instead of a bare backslash a browser would read as a slash.

- CHANGELOG: relabel the Python 3.8->3.10 bump as a breaking change with
  the whole-package blast radius, EOL rationale, and a migration note.

Adds regression tests for each; full auth suite passes (308 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Latest hardening round for the OIDC device-flow module, incorporating a
code-review pass over the working tree.

Transport (_http.py):
- _parse_retry_after: bound the digit-string length before int(). On
  Python >= 3.10.7 int() raises ValueError past 4300 digits, and this runs
  inside post_form before its own try/except, so a hostile IdP / on-path
  proxy could leak a raw ValueError past the module's typed-error contract.
  Only a short run of ASCII digits is accepted; anything longer reads as
  absent and the caller's fixed back-off applies.

Token lifecycle (_device.py):
- Reject a blank (empty or whitespace-only) served token on both the
  network and persisted paths: a run of spaces passed the printable-ASCII
  gate and would be cached and sent as "Bearer <spaces>" instead of failing
  once with the clear "missing required token" error.
- _normalize_url: re-bracket IPv6 literals to match the on-disk
  _canonical_endpoint, so two distinct endpoints ("[::1]:9000" vs
  "[::1:9000]") can't collapse to the ambiguous "::1:9000" and share one
  in-memory cache entry while staying separate on disk.

Token store (_store.py):
- Refuse a symlinked store-dir leaf (lstat) so the plaintext token files
  can't be redirected outside the owner-only directory.
- Treat a future-dated lock mtime as fresh (never steal a possibly-live
  lock when our clock reads behind it) and raise the lock-stale floor to
  300s for fsync/scheduling headroom above the ~240s network envelope.

Tests / example:
- Regression coverage for all of the above, including proving the
  SQLAlchemy per-connection token fetch is non-interactive (raises
  OidcInteractionRequired from a pool thread rather than prompting).
- Example: close the psycopg connection via contextlib.closing (psycopg2
  leaves it open on __exit__).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The slow-path shared-cache promotion set self._tokens from the shared MemoryCache without moving self._last_persisted_refresh_token, unlike every other adopter (_adopt, _store, clear). That desync made _refresh_under_lock's `refresh_token == _last_persisted_refresh_token` gate misread a peer-rotated adopted token as "our save failed, in-memory is newer than disk" and skip the token-store re-read -- so it refreshed a token a peer had already rotated away (revoked) and re-prompted, while the peer's valid refresh token sat unused on disk.

Reachable with a rotating-refresh-token IdP, a FileTokenStore, and multiple OidcDeviceAuth instances sharing the process-global cache across processes; fail-safe (never serves a wrong/expired token) but defeats the silent cross-instance/restart resume the store exists to provide.

Adopt the cache token's refresh token into the marker, exactly as _adopt does for a disk load. Adds a regression test that fails without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OidcDeviceAuth's docstrings reference :class:`~questdb.auth.Renderer` for the renderer= parameter, but Renderer was not exported, so the -nW (nitpicky, warnings-as-errors) docs build could not resolve the target and broke ./proj doc. Export Renderer from the package (__init__ + __all__) and autodoc it in api.rst, and document its callback contract (untrusted MITM-tamperable input, hold-the-lock/no-reentrancy, best-effort) so custom renderers are a proper public extension point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@glasstiger

Copy link
Copy Markdown
Author

Code review — feat: interactive OIDC device-flow authentication (questdb.auth)

Reviewed at level 3 (full multi-agent pass + per-finding source verification). This PR is pure Python — a new questdb.auth package (5,319 lines) touching no Cython/C-ABI code — so the review targeted the actual mission-critical surface: security (token leakage, MITM, header/conninfo injection, origin/redirect validation, homoglyph/control-char prompt spoofing) and correctness (token lifecycle, concurrency, cross-process persistence).

Headline: no Critical or Moderate findings. This is unusually hardened, defensively-written, and thoroughly-tested code (316 tests). Every credential-leak / injection / spoofing / race vector I could construct was already defended and covered by a test. What remains are Minor hardening and test-robustness items.

Critical / Moderate

None.

Minor

1. Persisted non-served token skips the control-char / CRLF gate — _device.py:1176-1184
_tokenset_from_persisted gates only the served token with _has_only_token_chars; the non-served token gets only _str_or_none (no char screen). The network path (_tokenset_from_response:1306-1307) gates both access_token and id_token via _safe_token_or_none. So a groups-mode entry whose access_token carries \r\n is adopted un-screened and re-persisted verbatim by _snapshot.
Not exploitable today (_select only returns the served kind, and both cache_key and the store fingerprint bucket by groups_in_token, so the tainted field can never become the served token), but it's an asymmetry in a security boundary and the fix is one line — _safe_token_or_none is already in the same file (_device.py:294):

access_token = _safe_token_or_none(persisted.access_token)
id_token = _safe_token_or_none(persisted.id_token)

This makes the persisted and network ingestion paths apply an identical screen, matching the parity the :1301-1303 comment already claims.

2. Head-read watchdog can tear down a healthy socket in the open()release() window — _http.py:445-456
The head_timer fires watch.shutdown() at start+timeout. When a head read completes right at the deadline (T_head ≈ timeout), the timer can fire in the gap between open() returning (line 450) and watch.release() (line 455) — where _done is still False and _sock is set — shutting down the live socket _read_body is about to use, producing a spurious OidcNetworkError. This contradicts the docstring guarantee at :452-454 / :192-195 that a late fire "can't tear down the healthy socket." Impact is low and recoverable (poll treats it as transient and retries; refresh retries later) and the trigger is narrow, but it is a real bug. Fix: neutralize a fire that races a successful return (e.g. release before the body read can begin, or drop a shutdown once open() has returned).

3. Discovery document's own issuer field is never validated — _discovery.py:578-592
resolve_config reads only token_endpoint / device_authorization_endpoint from the .well-known doc; doc.get('issuer') is never checked against the pinned issuer (RFC 8414 §3.3 issuer validation). The well-known test fixtures all carry an 'issuer' field that is dead payload. Not a live vulnerability — the doc is fetched over TLS directly from the out-of-band-pinned issuer origin (insecure=False), so it can't be substituted, and a hostile pinned origin would echo a matching issuer anyway. It's a spec-conformance / misconfiguration-diagnostic gap; worth adding the check (reject when doc['issuer'] is present and ≠ the pinned issuer) plus a test.

4. Concurrency tests can hang CI instead of failing — test_auth.py:5677 (+ siblings)
test_in_lock_serializes_concurrent_acquirers uses an unbounded t.join() with no is_alive() assertion — a serialization/deadlock regression in the O_EXCL lock protocol would hang the whole suite indefinitely rather than fail fast. Several sibling concurrency tests use join(timeout) without a following assertFalse(t.is_alive()) (e.g. :5583, :600, :828). Add a bounded join + is_alive() check, matching the well-built ones (test_concurrent_steal_does_not_hang_or_leak:5616, test_renderer_reentrant_call_raises_not_deadlocks:2589).

5. The only behavioral TLS-rejection test silently skips in CI — test_auth.py:4296
test_untrusted_server_certificate_is_rejected skipTests when cryptography is absent, and this PR's ci/pip_install_deps.py does not install cryptography. So in CI, request-path cert verification is guarded only by the static posture assertion, not the behavioral handshake test that would catch a verification silently bypassed on the request path. Either add cryptography to the CI test deps or accept the skip explicitly. Low risk (build_ssl_context wraps stdlib ssl.create_default_context()).

Minor nits (optional)

  • FileTokenStore.save leaks the mkstemp fd if os.fdopen() itself raises before the with takes ownership (_store.py:564-571). Theoretical; the temp file is still cleaned. Guard with a closed flag in the finally to be airtight.
  • _steal_stale_lock can leave an orphaned fresh lock if a live holder releases exactly mid-steal (_store.py:812-843). Safety-neutral (atomic writes hold); the identity degrades to lock-free until the orphan ages past lock_stale. Worth a one-line comment at the restore site.
  • _normalize_url (cache_key) collapses an explicit :0 port while _canonical_endpoint (store key) keeps it (_device.py:1718 vs _store.py:237) — a hair's-breadth deviation from the "same identity distinctions" contract. :0 is unconnectable, so cosmetic.
  • _coerce_port silently truncates a float port (int(8812.9)→8812, _adapters.py:123) rather than rejecting a non-integer; result is still a valid port.
  • FileTokenStore.load/save/clear/in_lock lack their own docstrings (_store.py:514+), inheriting the ABC's; may render doc-less on the concrete class page since docs/api.rst doesn't set :inherited-members:.

Dismissed after verification (false positives / over-rated)

  • __all__ not sorted (RUF022) — it is sorted (codepoint order, classes before lowercase functions).
  • _pg_module() doesn't chain the ImportError — it already does raise ImportError(...) from e (_adapters.py:67-70).
  • docs/auth.rst example missing TimestampNanos import — present at docs/auth.rst:96.
  • _opener() should take insecure to enforce redirect policy — moot: _NoRedirect is installed unconditionally on every opener variant and refuses all redirects; _require_secure is enforced in request().
  • Stale _questdb.py / QuestDB class / top-level connect() references — the module was reorganized into _adapters.py+_store.py; no doc/changelog/example references a removed symbol.
  • OidcError can escape if an arg's __str__ raises — not reachable; all untrusted fields come from json.loads, whose outputs have total str().
  • Memoize cache_key to avoid per-call URL parsing — refuted: cache_key is evaluated zero times on the valid-cached fast path (read only in the tokens is None branch, past the fast-path return). The fast path is genuinely lock-free and allocation-free.

Verdict

Approve — no blocking issues. The five Minor items are hardening/test-robustness improvements, none security-critical given the mitigations. #1 (one-line gate symmetry) and #4 (bounded join) are the two most worth doing before merge.

Notable strengths (relevant to the verdict): the parse-vs-connect authority defense, the unconditional redirect refusal, the issuer origin+path pinning with the discovery-confirmed exemption, the _strip_control Unicode-category sanitizer, the generation-CAS cache protocol, and the O_EXCL cross-process lock are all correctly implemented, match real stdlib behavior, and are backed by adversarial tests.

🤖 Level-3 review via the repo's review-pr skill (Claude Code). ~14 draft findings → 5 Minor + 5 nits confirmed, ~9 dismissed on source verification.

Address the level-3 review findings on the questdb.auth module.

Security:
- _tokenset_from_persisted: screen BOTH wire-bindable tokens (access_token
  and id_token) through _safe_token_or_none, not just the served kind, so a
  control/CRLF char in a non-served persisted token can no longer land in the
  TokenSet (and be re-persisted verbatim) — symmetric with the network path.
- discover_device_endpoint_from_idp: reject a discovery document whose own
  'issuer' does not match the pinned issuer (RFC 8414 3.3), trailing-slash-
  insensitive; absent/non-string tolerated, so it is strictly a tightening.

Robustness / nits:
- _http: correct the head-read watchdog docstrings to describe the residual
  open()->release() window honestly (rare, recoverable via a retried
  OidcNetworkError) instead of overclaiming it cannot occur.
- _store.save: close the mkstemp fd if os.fdopen raises before taking
  ownership; document the orphaned-lock window at the _steal_stale_lock
  restore site; add docstrings to the FileTokenStore method overrides.
- _device._normalize_url: keep an explicit :0 port (compare against None,
  not truthiness) to match _canonical_endpoint's identity distinctions.
- _adapters._coerce_port: reject a non-integral float port instead of
  silently truncating it through int().

Tests / CI:
- Bound the previously-unbounded joins in the in_lock serialization and
  sqlalchemy per-connect tests and assert is_alive()==False, so a deadlock
  regression fails cleanly instead of hanging the suite.
- ci/pip_install_deps.py: install cryptography so the behavioural
  TLS-rejection test runs in CI rather than silently skipping.
- New regression tests: discovery issuer mismatch/trailing-slash, persisted
  non-served token screening, and a non-integral float pg_port. Verified
  each fails without its corresponding source fix. Full suite: 319 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@glasstiger

Copy link
Copy Markdown
Author

Code review — feat: interactive OIDC device-flow authentication (questdb.auth)

Reviewed at level 3 (full multi-agent pass + per-finding source verification). This PR is pure Python — a new questdb.auth package (~5,300 lines, 10 modules) touching no Cython/C-ABI code — so the review targeted the actual mission-critical surface: security (token leakage, MITM/origin confusion, header/conninfo injection, prompt spoofing) and correctness (token lifecycle, concurrency, cross-process persistence). I read all ten src/questdb/auth/*.py modules in full, mapped the change surface, ran parallel adversarial passes (security, correctness, concurrency/resources, tests, API/docs, cross-context, fresh-context), and source-verified every draft finding.

This is a re-review at the current tip (4559587). The earlier level-3 review on this PR was posted at an older commit — I re-verified each of its items against current HEAD, and all 5 Minor findings and the fd-leak nit are already fixed (see Downgraded).

Headline: no Critical or Moderate findings. Every credential-leak / injection / spoofing / origin-confusion / race vector I could construct was already defended and backed by an adversarial test. What remains are Minor correctness-robustness and test/doc items.

Critical / Moderate

None.

Minor

1. Rotated refresh token is silently discarded on the "200-but-missing-required-kind" refresh branch — _device.py:1085-1103 (_try_refresh_locally).
When a silent refresh returns HTTP 200 carrying a newly rotated refresh_token but without the required token kind (groups mode, IdP re-issues access_token+refresh_token but no id_token), _refresh (_device.py:1370-1377) preserves the rotated token into refreshed, but _try_refresh_locally then hits if self._has_required_token(refreshed): … return None and drops it without calling _store. _acquire (:1005-1006) evicts the old token and either re-prompts (interactive) or raises OidcInteractionRequired (non-interactive pool path).
Nuance (why Minor): this only triggers in groups mode with an IdP that doesn't re-issue the id_token on refresh — a case the code already anticipates (:1097-1099). For a deterministic IdP, silent id_token maintenance is fundamentally impossible there, so a re-prompt is the correct, unavoidable outcome, and preserving the rotated token wouldn't change it. The discard only produces a worse-than-correct result for a pathological non-deterministic IdP. The regression test test_refresh_without_id_token_non_interactive_does_not_loop (test_auth.py:1717) covers the no-id_token path but with a response that omits refresh_token, so the rotation-discard sub-case is untested.
Fix (optional): before return None, if refreshed.refresh_token != tokens.refresh_token, persist/adopt the rotated refresh token so a later attempt refreshes with the live token rather than replaying the (server-rotated, likely-revoked) old one — plus a regression test with a rotating no-id_token refresh response.

2. Two public __init__ methods lack docstrings — _errors.py:38 (OidcError.__init__) and _errors.py:90 (OidcDeviceFlowError.__init__).
These are the only public (non-underscore) API members with no docstring — the entirety of what drags the docstring-coverage metric to 15.81%. Impact is limited to the metric: docs/api.rst documents these via .. autoexception:: without :members:, so the signatures aren't rendered anyway. One-line docstrings resolve it.

3. Minor test-coverage gaps (test/test_auth.py). Real defenses with no direct test:

  • build_ssl_context env-var path (REQUESTS_CA_BUNDLE/SSL_CERT_FILE) and the capath directory branch (_http.py:82-83) — the documented TLS-intercepting-proxy mechanism is never exercised; only an explicit ca_bundle= path is.
  • The non-string-file-value branch of _audience_matches/_issuer_matches (_store.py:914-937) — the load-side type gate against a hostile "audience": 123 / "issuer": ["x"] — is untested (only None-vs-str and str-vs-str are).
  • _reject_confusable_authority with a bare C0-control/DEL (\x01, \x7f) inside the netloc (_discovery.py:319) — tested for \t\n\r/%/@/non-ASCII, but not the control chars urlparse keeps in .netloc.
  • Adapter host guard _ILLEGAL_HOST_CHARS (_adapters.py:55) with a NUL/DEL host — ;/=/space/% are tested, the \x00-\x1f/\x7f range is not.

Minor nits (optional, borderline)

  • _strip_control keeps category-Mc spacing marks uncapped (_render.py:389; only Mn is Zalgo-capped). A deliberate tradeoff (comment at :331-336 keeps Mc for legitimate Indic text) and cosmetic-only (the host is separately IDNA-normalized) — near-dismissible.
  • An issuer= carrying a query/fragment produces a malformed .well-known URL via well_known_url's string-concat (_discovery.py:421); not exploitable (host unchanged → discovery 404s).
  • questdb_url's authority isn't confusable-checked before the /settings GET (_discovery.py:139), unlike issuer/endpoints. Low risk: that GET carries no secret and every returned endpoint is re-vetted before any credential POST.
  • cache_key excludes device_authorization_endpoint while the on-disk TokenStoreKey.hash includes it (_device.py:765 vs _store.py:329). Benign: the token_endpoint (where credentials are exchanged) is keyed by both; asymmetry costs at most one extra silent refresh, never a cross-identity serve.

Downgraded (verified false positives / non-issues)

Earlier review's findings — all resolved at current HEAD:

  • "Persisted non-served token skips the control-char/CRLF gate"FIXED: _tokenset_from_persisted now runs both tokens through _safe_token_or_none (_device.py:1184-1185).
  • "Discovery doc's own issuer never validated"FIXED: RFC 8414 §3.3 check at _discovery.py:464-472.
  • "Concurrency tests can hang CI"RESOLVED: zero unbounded join()s; all 12 non-daemon worker joins are bounded and paired with assertFalse(t.is_alive()); the only assertion-less joins are on daemon=True reapers.
  • "TLS-rejection test silently skips in CI"RESOLVED: ci/pip_install_deps.py:116 now installs cryptography, so test_untrusted_server_certificate_is_rejected runs the real handshake.
  • nit "FileTokenStore.save leaks the mkstemp fd if os.fdopen raises"FIXED: fd_owned guard (_store.py:581-603).

Other candidates dismissed on verification:

  • MemoryCache.release() remaining < 0 strands a generation entry — unreachable in current code (every generation() is paired with one release()); a documented future-caller guard, bounded to one entry even if reached.
  • _backoff_interval no forward progress at the 60s cap under repeated slow_down — by design; the device-code deadline terminates the loop. RFC technicality only.
  • Head-read watchdog open()release() race — accurately documented (_http.py:191-198/:456-464) as irreducible for a wall-clock timer and safe-degrading (retryable OidcNetworkError, never a wrong/truncated token).
  • :0-port cache_key vs store-key asymmetrynot present; both _normalize_url (:1730) and _canonical_endpoint (:237) compare port against None and keep :0.
  • _normalize_url vs _canonical_endpoint render a path-less endpoint differently — benign: separate key spaces (never cross-compared), and RFC 8628 endpoints always carry a path.
  • settings_config on a non-dict /settings — safe (returns {}).
  • Leading/trailing spaces survive _safe_token_or_none — not an injection vector (space can't break a header/conninfo field; CR/LF/;/= are already rejected).

Verdict

Approve — no blocking issues. Unusually hardened, defensively-written, thoroughly-tested code (319 tests). Verified defenses include: TLS-can-never-be-disabled, insecure=True never downgrading the IdP, the repr=False + _strip_control credential-leak defenses, the _safe_token_or_none CR/LF gate on both network and persisted paths, _reject_confusable_authority's urlparse-vs-http.client divergence coverage, issuer origin+path pinning with the discovery-confirmed exemption and full ..-traversal decoding, the plaintext-/settings guard, unconditional redirect refusal, the preferences-vs-config split, the RFC 8414 doc-issuer check, the display/open/QR single-vetted-target unification, the generation-CAS resurrection defense, and the O_EXCL cross-process lock. The two documented irreducible races degrade to "one wasted round-trip / one re-prompt, never a wrong or torn token."

All findings are in-diff; out-of-diff is legitimately zero (the auth package is a self-contained leaf, imported only by the new tests/examples, and the PR modifies no existing exported API — ingress.pyx/.pyi, Sender/Buffer, src/questdb/__init__.py are untouched). The "also in this PR" CI/build changes (connection-error scoping in mock_server.py, version-agnostic dtype in test_dataframe.py, the 32-bit pandas guard, cryptography added non-fatally, the 3.8→3.10 bump across setup.py/docs/RELEASING.rst/proj.py) are all low-risk and correct.

Most worth doing before merge: #1 (persist the rotated refresh token on the missing-kind branch + regression test) and #3 (the build_ssl_context env-var and _*_matches non-string-file test gaps). Neither is security-critical given the mitigations.

🤖 Level-3 review via the repo's review-pr skill (Claude Code). 10 source modules read in full; parallel adversarial agents; per-finding source verification. Earlier review re-verified as stale (all items fixed).

glasstiger and others added 4 commits July 1, 2026 18:52
…an't diverge

_safe_link_url vetted the scheme, userinfo and host of the device-flow
verification URL but never read its port, so a URL with a non-integer or
out-of-range port (e.g. "https://host:70000/device") was returned verbatim as
the clickable href / webbrowser.open() / QR target. _display_url, however,
drops a junk port it can't render — so the user would read
"https://host/device" while the click / browser / QR went to a different port
on that host. That breaks the shown-vs-opened invariant _safe_target exists to
hold (the port-stripped display had made the divergence invisible).

Read .port inside the existing try so a malformed port raises ValueError and
the URL is rejected: it is then shown as inert, port-stripped text and never
made clickable/opened/scanned. Valid explicit ports are unaffected. Add a
regression test pinning the invariant.

Also correct the CHANGELOG: pyproject.toml's authoritative requires-python
already declared >=3.10 in the released 4.1.0, so pip has rejected 3.8/3.9
since then; this release only syncs the stale setup.py python_requires. Scope
the "nothing written to disk" note to the default (opt-in FileTokenStore
persists a refresh token).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge gaps

_request_device_code accepted a user_code / verification_uri that is a non-empty
string of only control / zero-width / exotic-space characters: it passed the
_str_or_none truthiness guard yet renders empty via _strip_control / _display_url,
producing an "Open  and enter code:" prompt with nothing to act on. Gate the two
DISPLAYED fields on their post-strip value (via _verification_uri, exactly what
the renderer shows) so such a response is rejected as non-conformant. device_code
keeps the raw check (it is sent, not shown); user_code keeps _str_or_none first so
a JSON number can't be coerced visible by _strip_control's str() fallback.

Also close review-flagged test gaps:
- strengthen the two clear() concurrency stress tests: seed a token whose
  id_token is DISTINCT from the mock-minted one, so a served value tells a stale
  cache-hit from a genuine re-acquisition — the seed == issued value made
  `token() != ID_TOKEN` a tautology a broken shared-cache CAS could pass;
- FileTokenStore: a mid-write failure (fdopen/fsync/rename) cleans up its .tmp
  and raises; load() errno routing (ELOOP/ENOTDIR -> None, EACCES/EIO -> raise);
  a non-string file audience/issuer is rejected; a control-char refresh_token is
  kept (only url-encoded to the IdP, never a header); a real cross-PROCESS
  save/load round-trip over the on-disk format;
- TerminalRenderer.on_waiting renders the MM:SS countdown;
- import-check examples/oidc_device_auth.py (not in examples.manifest.yaml).

Reword the test.py comment: the auth tests are pure-Python and run standalone,
but test.py imports questdb.ingress first, so the aggregated CI run builds the
extension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-prompt

Two edge cases found while reviewing the OIDC device-flow module.

- _validate_positive_number interpolated the offending value with {!r}.
  repr() on an int with >4300 digits itself raises ValueError (CPython's
  int->str limit, active on the 3.10 floor), so a huge-int timeout /
  default_interval escaped the module's typed-error contract as a bare
  ValueError instead of OidcConfigError. Fall back to a type description
  when repr() can't run.

- When a silent refresh was exhausted, _acquire evicted the shared-cache
  entry and ran the device flow unconditionally. A peer OidcDeviceAuth
  sharing the process-global cache may have stored a valid token for this
  identity during our refresh; evicting it and prompting was a needless
  re-prompt. Re-read the shared cache before evicting and adopt a valid
  peer token, syncing _last_persisted_refresh_token as the promotion path
  does. (Checking after the evict would not work — evict() drops the peer
  token first, so the re-read has to precede it.)

Both are covered by regression tests that fail without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tests

Minor findings from the review round (the Moderate fixes landed in the
previous commit).

- FileTokenStore.clear() now sweeps orphaned `<hash>*.tmp` siblings. save()
  writes the plaintext token into a temp before its atomic rename; a hard
  crash in that window left it behind (0600, never read back) and clear()
  removed only the .json, so a forgotten credential lingered on disk.

- _is_stale() uses lstat, so a symlink planted at the lock path is judged by
  the link's own mtime rather than a target a co-tenant keeps fresh; the
  residual future-dated-mtime case is documented as integrity-safe.

- Doc accuracy: scope "auto-refreshed" to sqlalchemy_engine (psycopg_connect
  captures the token once at connect time); document the adapters' host /
  pg_port / database params and OidcConfigError; correct FileTokenStore.load
  "never raises" (it raises on a hard I/O error); frame the 0600 file mode
  (not the 0700 directory) as the content protection; note the cross-process
  clear-vs-save race is not limited to the lock-failed path.

- Tests: _pg_module v3/v2/none selection order, an invalid-UTF-8 HTTP body via
  json(), a FIFO at the token path, and the groups_in_token=False refresh
  fall-back (mirroring the existing groups=True test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@glasstiger glasstiger changed the title feat: interactive OIDC device-flow authentication (questdb.auth) feat: OIDC device-flow authentication Jul 2, 2026
glasstiger and others added 4 commits July 2, 2026 12:08
Two credential-path hardening fixes found in review.

PG host guard (_adapters.py): replace the deny-list of conninfo
metacharacters with a positive allow-list of the characters a real
hostname / IPv4 / IPv6-literal can contain. The deny-list missed ',' —
a libpq MULTI-HOST separator ('host=a,b' tries both), so a tampered URL
could steer the connection, and the '_sso' token sent as the password,
to an attacker host that merely reads next to the real one — and '/',
which libpq treats as a Unix-socket directory. The allow-list closes the
whole redirection/injection class at once. Underscore stays allowed,
matching _render._SAFE_HOST_RE.

HTTP body read (_http.py): read1() (used for the chunked-dribble
watchdog) does not enforce Content-Length — on a body that declares N
bytes but delivers fewer then closes, it returns the short data then a
clean EOF, so a truncated (yet still JSON-parseable) token / config
response was handed back as a complete 200. http.client leaves the owed
count on resp.length, so treat a truthy length at EOF as a truncation
and raise OidcNetworkError. Chunked bodies (length None) and non
-http.client readers (no length attr) are unaffected.

Add regression tests for both (comma/unix-socket/userinfo host
rejection; truncated Content-Length via a unit stub and a real socket)
and update the cross-reference comments to the renamed _LEGAL_HOST_RE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve_config tracked endpoint provenance by identity (`is not None`)
but chose the value by truthiness (`or _resolve_endpoint(...)`), so an
empty-string override — a common "unset" sentinel like
token_endpoint=os.environ.get("TOK", "") — was stamped caller-explicit
(trusted) while its value came from the untrusted /settings response.
That false provenance skipped the plaintext-channel guard and both
issuer pins, letting a tampered /settings route the device code and
refresh token to an attacker origin. Normalize empty->None up front so
an empty override behaves exactly like an omitted one.

FileTokenStore.load() stat'd the path then did a blocking open(): a
co-tenant swapping the regular file for a FIFO in that window hung the
open forever, pinning the acquisition-lock-holding thread. Open
O_NONBLOCK and re-validate the opened fd with fstat, closing the TOCTOU.

_try_refresh_coordinated's post-failure fall-through re-refreshed with
the stale tokens argument; a custom TokenStore whose in_lock raised
after its action already rotated the refresh token would replay the
spent token and trip the IdP's reuse detection. Re-consult the freshest
in-memory token first, and document the in_lock contract OidcDeviceAuth
relies on. The bundled FileTokenStore was already immune to both.

Adds regression tests for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mation

M1: a non-string issuer= reached urlparse (via resolve_config's authority
vetting and the discovery-URL build) before OidcDeviceAuth.__init__ could
type-check it, so from_questdb leaked a raw AttributeError/TypeError instead of
an OidcError, escaping the module's typed-error contract. safe_urlparse now maps
TypeError/AttributeError too (also covering a non-string QuestDB URL), and
resolve_config type-checks issuer early with the same clear message the direct
constructor already raises.

M2: the /settings-vs-IdP-discovery endpoint confirmation used raw string
equality, so a legitimate split-origin IdP (Google/Auth0/Azure) whose two
sources spelled one endpoint with a trailing slash, an explicit default port, or
a case difference was wrongly rejected by the issuer-origin pin. Compare on the
canonical endpoint form instead (reusing _canonical_endpoint, the same
canonicalization the cache key and store key use), so a differing query stays a
distinct routing target and an unparseable value fails closed rather than
raising.

Adds regression tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… marks

m1 (RFC 8628 §3.5): a non-conformant `429 {"error":"slow_down"}` was handled by
the transient 429/5xx arm, which runs before the dedicated slow_down arm, so a
low Retry-After made the poll interval DROP right after the IdP asked to slow
down. The 429/5xx arm now passes at_least_increment when the body carries
slow_down, so the interval still increases by at least 5 (floor-bounded either
way); a plain 429 keeps honoring Retry-After verbatim.

m2: groups_in_token reached the OidcDeviceAuth constructor un-coerced. A truthy
non-bool (e.g. 2, from an env read without a cast) is used truthily in memory,
but the store hashed the file as groups=1 while _parse_and_verify compared the
raw value, so a persisted entry failed its OWN reload (`True != 2`) and
re-prompted every restart. Coerce it to bool in __init__ (mirroring
from_questdb), and make the store self-consistent for a direct TokenStoreKey:
_serialize writes a boolean and _parse_and_verify compares bool-to-bool.

m3: seven invisible Default_Ignorable non-spacing marks (category Mn) — the
combining grapheme joiner (U+034F), the Mongolian free variation selectors
(U+180B-U+180D, U+180F) and the Khmer inherent vowels (U+17B4, U+17B5) —
survived _strip_control's "keep accents" rule and could hide payload in a
user_code / identity / URL, like the FE00-FE0F variation selectors already
stripped. Add them to _STRIP_EXTRA.

Adds regression tests for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@glasstiger

Copy link
Copy Markdown
Author

Code review — feat: OIDC device-flow authentication (questdb.auth)

Reviewed at level 3 (full multi-agent adversarial pass + per-finding source verification) at the current tip e50f490. This is effectively a re-review: the two earlier level-3 reviews were posted before a history rewrite (the last-reviewed commit 4559587 is no longer in HEAD's ancestry), and there are 5 newer fix(auth): commits since — so I re-verified everything against current source and treated prior line numbers as stale.

This PR is pure Python — a new self-contained questdb.auth package (9 modules, ~5,700 LOC + ~7,000 LOC of tests) touching no Cython/C-ABI code — so the review targeted the real surface: transport/TLS, credential-endpoint origin pinning, token lifecycle, cross-process concurrency, and injection/spoofing. Ten parallel agents read current source (not just the diff); every draft finding was then source-verified, including a direct code trace to adjudicate a conflict between agents on the one security-relevant Moderate candidate.

Headline: no Critical or Moderate findings. Every attacker-controlled vector I constructed (tampered /settings, MITM'd IdP, hostile JWT/token fields, conninfo/header injection, homoglyph/control-char prompt spoofing, path traversal, redirect-based token leak) was already defended and backed by an adversarial test (346 auth tests, all green). What remains are three Minor items and a few optional Nits.

Critical / Moderate

None.

Minor

1. Rotated refresh_token discarded on the "200-but-missing-required-kind" refresh branch — _device.py:1148-1151 (_try_refresh_locally).
When a silent refresh returns HTTP 200 carrying a newly rotated refresh_token but without the required token kind (groups mode; IdP re-issues access_token+refresh_token but no id_token), the if self._has_required_token(refreshed): gate is False, so it return None without ever calling _store/_persist_if_rotated. The rotated token is thrown away; _tokens keeps the old refresh token, which the IdP just rotated (hence spent). _acquire evicts and re-prompts, and on a later attempt/restart the spent old token is replayed → IdP refresh-token-reuse rejection → an extra re-prompt.
Nuance (why Minor): only bites groups mode against an IdP that rotates the refresh token but doesn't re-issue id_token on refresh. For a deterministic IdP, silent id_token maintenance is impossible there anyway, so a re-prompt is the correct unavoidable outcome and preserving the rotated token wouldn't change it — it only produces a worse-than-correct result for a pathological non-deterministic IdP. Recoverable (a re-prompt, never a wrong or served token). test_refresh_without_id_token_non_interactive_does_not_loop covers the no-id_token path but with a response that omits refresh_token, so the rotation-discard sub-case is untested.
Fix: before return None, if refreshed.refresh_token != tokens.refresh_token, adopt/persist the rotated refresh token (so the next attempt refreshes with the live token rather than the revoked one), then still return None to drive the interactive fallback — plus a regression test with a rotating, no-id_token refresh response.

2. A non-string ca_bundle= escapes the typed-error contract as a raw TypeError_http.py:82-85 (build_ssl_context).
build_ssl_context catches only (OSError, ssl.SSLError) (line 85), but a non-string ca_bundle (a list/dict/float, or a stray int) makes os.path.isdir(ca) / ssl.create_default_context(cafile=ca) raise a bare TypeError that propagates out of OidcDeviceAuth.__init__ and from_questdb, despite the documented contract (lines 79-80) to map bad-bundle errors to OidcConfigError. Careless-caller only — not attacker-reachable (the REQUESTS_CA_BUNDLE/SSL_CERT_FILE env fallbacks are always str), and the other constructor args are type-validated in __init__, so this is an inconsistency, not a vuln. test_bad_ca_bundle_raises_config_error covers only string paths, so it's untested.
Fix: add TypeError to the except at line 85, or isinstance-check ca_bundle in __init__ alongside the existing typed-arg validation.

3. build_ssl_context's env-var and capath directory branches are untested — _http.py:75-76, 82-83.
The only test exercises an explicit string ca_bundle= file path. The documented TLS-intercepting-proxy mechanism — CA resolution from REQUESTS_CA_BUNDLE then SSL_CERT_FILE, and the isdir → capath= branch — has no test. A regression that read the wrong env var, dropped the SSL_CERT_FILE fallback, or passed a directory as cafile= (a silent no-trust) would not be caught. This is TLS trust configuration, so worth a test.
Fix: add a test that points each env var at the suite's runtime-generated cert and asserts the returned context trusts it, plus a capath directory case.

Minor nits (optional)

  • slow_down in a non-conformant 5xx body without Retry-After isn't honored — _device.py:1690-1702. A 500 {"error":"slow_down"} with no Retry-After enters the 5xx arm but the inner guard status == 429 or retry_after is not None is False, so the RFC 8628 §3.5 +5s step is skipped and the interval stays flat. Doubly-non-conformant (RFC returns slow_down with HTTP 400, handled correctly by the dedicated arm at 1723); the poll continues and the deadline bounds it. Optional: fold body.get('error') == 'slow_down' into the guard.
  • well_known_url string-concatenates — _discovery.py:446. An issuer= carrying a query/fragment yields a malformed .well-known URL. Not exploitable (host unchanged → 404, and the RFC 8414 issuer-match would reject any doc); build via urlunparse dropping query/fragment for cleanliness.
  • Wrong-type host=/url in the PG adapters raise AttributeError/TypeError_adapters.py:93,106. Same class as Ma/examples only #2 (careless caller, nothing injectable reaches the driver — the call crashes before producing a host). Add an isinstance guard for contract consistency.
  • Deliberate, documented Unicode tradeoffs (not bugs): _strip_control leaves category-Mc spacing marks uncapped, and e50f490 strips Khmer U+17B4/U+17B5 which could alter a legitimate Khmer identity string — both are documented, visually-inert choices.
  • Cosmetic: CHANGELOG.rst:5 has a stray blank line before its underline; the frozen-dataclass fields in docs/api.rst render as bare field: type (no attribute docstrings).

Downgraded (false positives / over-rated, dismissed after source verification)

  • _last_persisted_refresh_token desync → refresh-token reuse (flagged Moderate by the fresh-context agent) — FALSE POSITIVE. Claim: when the shared-cache CAS in _store drops (a concurrent clear() bumped the generation), _tokens advances to the rotated token but the marker doesn't, and the _refresh_under_lock gate (:1170) then replays a peer-rotated token. Traced and disproved: when the CAS drops, store_if_current returns False and _persist_if_rotated is skipped (:1073), so the rotated token lives only in self._tokens — never written to the shared cache or to disk, so no peer/process can obtain or spend it. Refreshing with it later is therefore guaranteed-live and correct; the gate's "marker ≠ in-mem ⇒ in-mem is strictly newer/un-shared ⇒ don't regress to disk" logic is right. Independently cleared by the two agents that deep-audited _device.py and _store.py, and the flagging agent's own experiment showed a targeted marker-sync fix did not change the measured reuse rate — i.e. the desync is not the cause. The residual multi-process reuse it measured is the separately-documented best-effort cross-process file-lock limitation (two processes sharing an identity when the O_EXCL lock degrades), whose consequence is a recoverable re-prompt, not credential compromise.
  • clear()'s orphan-temp sweep deletes a concurrent save()'s live temp (flagged Moderate) — over-rated to Nit. In the normal single-auth-instance flow, clear and save serialize through the O_EXCL in_lock; only in the degraded lock-free window can the sweep unlink a live temp, whereupon save's os.replace raises OSError → swallowed (best-effort) → continues with the valid in-memory token. Integrity-safe (the atomic-write invariant holds); costs one lost persist = one extra silent refresh next restart.
  • body.get('error') at _device.py:1702 could hit body=None — unreachable in production (post_form guarantees a dict; only a mock returning body=None triggers it).
  • CodeRabbit's actionable items — all already correct at HEAD: __all__ is codepoint-sorted (RUF022-clean); _pg_module does raise ImportError(...) from e; the docs/auth.rst example does import TimestampNanos; _opener refuses redirects unconditionally on every variant (so passing insecure into it is moot).
  • Docstring coverage 15.81% (CodeRabbit pre-merge warning) — every public symbol in __all__ (all 16, plus their public methods) has a docstring; the metric counts private _-prefixed helpers, consistent with the repo's convention (ingress.pyx). Non-blocking.

Verdict

Approve — no blocking issues. Unusually hardened, defensively-written, thoroughly-tested code. Verified defenses include the layered parse-vs-connect authority defense, unconditional redirect refusal, issuer origin+path pinning with full ..-traversal decoding (percent/double-encoded/backslash/;param), the _safe_token_or_none CR/LF gate on both the network and persisted token paths, the _strip_control Unicode-category sanitizer (with the display/open/QR single-vetted-target unification), the preferences-vs-config split, the plaintext-/settings pin requirement, the generation-CAS cache protocol, and the O_EXCL cross-process lock — all correctly implemented, matching real stdlib behavior, and backed by adversarial tests.

All findings are in-diff; out-of-diff is legitimately zeroquestdb.auth is a brand-new self-contained leaf package imported only by its own new tests/example, and the diff modifies no existing exported API (ingress.pyx/.pyi, Sender/Buffer, src/questdb/__init__.py are untouched). The "also in this PR" CI/build changes (mock-server connection-error scoping, version-agnostic dtype, the 32-bit pandas guard, cryptography added to CI deps, the 3.8→3.10 bump across setup.py/docs/RELEASING.rst/proj.py) are all low-risk and consistent.

Most worth doing before merge: #1 (persist the rotated refresh token on the missing-kind branch + a rotating-no-id_token regression test). #2 and #3 are quick TLS-config hardening. None is security-critical given the mitigations.

🤖 Level-3 review via the repo's review-pr skill (Claude Code). Re-review at tip e50f490 after history rewrite; 9 source modules read in full; 10 parallel adversarial agents; per-finding source verification. ~15 draft candidates → 3 Minor + ~5 Nits confirmed, ~7 dropped (incl. the one Moderate-flagged reuse candidate, disproved by code trace).

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