Skip to content

feat: interactive OIDC device-flow authentication (questdb.auth)#133

Open
glasstiger wants to merge 22 commits into
mainfrom
ia_oidc_device_flow
Open

feat: interactive OIDC device-flow authentication (questdb.auth)#133
glasstiger wants to merge 22 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, HTTP, 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>"}

The integrated session — query to a DataFrame and feed adapters:

from questdb.auth import connect

qdb = connect("https://questdb.example.com:9000")   # interactive sign-in
df = qdb.sql("SELECT * FROM trades LIMIT 10")        # REST /exec -> pandas
engine = qdb.sqlalchemy_engine()                     # PG-wire, token as _sso
with qdb.sender() as sender:                         # ingestion (ILP over HTTP)
    ...

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.

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,
    while a transient network error is surfaced (not re-prompted). A lock
    serializes refresh so parallel cells/threads don't double-prompt. Cache
    backends: "memory" (default — process-global, survives notebook cell
    re-runs), None (no cache, prompt every time), or a custom TokenCache
    instance. Tokens are never written to disk.
  • Connection adapters: pandas (REST /exec), SQLAlchemy, psycopg/psycopg2,
    and the ingestion Sender.
  • 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.
  • Typed errors: every failure path raises an OidcError subclass
    (OidcConfigError, OidcNetworkError, OidcInteractionRequired,
    OidcDeviceFlowError, OidcTimeoutError, OidcAuthError) — malformed config,
    HTTP/URL errors and bad server payloads are mapped to these rather than leaking
    raw ValueError / AttributeError / http.client exceptions.

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.
  • 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).
  • 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 [1s, 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.
  • Terminal output is sanitized — C0/C1 control characters (incl. ESC) are
    stripped from untrusted device-response fields (verification_uri,
    user_code, IdP error_description, JWT-derived identity) before they reach
    the TTY, so a MITM'd response can't inject ANSI escapes to spoof the prompt.
    The Jupyter renderer already HTML-escapes its output.
  • Credentials kept out of logsTokenSet is immutable (frozen) and keeps
    the access/id/refresh tokens out of its repr, so a token can't leak into a
    log line or traceback. Adapters avoid logging the token / PG DSN.
  • Honours HTTPS_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, and ca_bundle=.

Dependencies & Python support

token() / headers() need nothing beyond the standard library. pandas,
sqlalchemy, psycopg / psycopg2, qrcode and IPython are imported
lazily, only when used. Minimum supported Python is raised to 3.10.

Docs, examples & tests

  • New guide docs/auth.rst (:ref:oidc_auth), API reference in docs/api.rst
    (incl. TokenCache, TokenSet, MemoryCache, NullCache), 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, REST/PG adapters, concurrency, endpoint validation,
    cache-key derivation, and transport/renderer security.

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 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
    (ConnectionError family), 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/).

Summary by CodeRabbit

  • New Features

    • Added interactive OIDC device-authorization authentication with token caching and automatic refresh (including a file-backed option).
    • Introduced a high-level authentication/session helper with REST SQL execution, SQLAlchemy/PG connectivity, ingestion support, and Jupyter/terminal sign-in prompts, plus a new device-auth example.
  • Documentation

    • Added OIDC authentication guide, expanded auth API reference, and updated docs navigation and dependency/installation notes.
  • Compatibility

    • Raised minimum supported Python version to 3.10.
  • Tests

    • Added comprehensive authentication/discovery/adapter/concurrency/security tests; improved CI/test robustness.

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

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@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
Comment thread src/questdb/auth/__init__.py
Comment thread src/questdb/auth/_http.py
Comment thread test/test_auth.py Outdated
glasstiger and others added 21 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>
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