Skip to content

feat(qwp): authenticate third-party browser apps with a subprotocol credential - #68

Open
glasstiger wants to merge 4 commits into
mainfrom
ia_browser_client
Open

glasstiger wants to merge 4 commits into
mainfrom
ia_browser_client

Conversation

@glasstiger

@glasstiger glasstiger commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Tandem

Description

Let third-party web applications, served from an origin other than QuestDB's, authenticate browser QWP connections. A browser cannot set Authorization on a WebSocket upgrade, and with the tandem server change QuestDB ignores cookies on a cross-origin QWP upgrade. It instead accepts a credential in the subprotocol offer from origins listed in qwp.browser.allowed.origins.

  • New auth option on QwpBrowserWebSocketOptions and QwpBrowserClusterOptions. It takes a fixed bearer or basic credential, or a QwpBrowserAuthProvider function that the client calls before every connect, reconnect, and failover attempt, passing an AbortSignal that fires when the attempt is abandoned. The function form lets a long-lived sender or query session pick up a refreshed OIDC access token instead of reconnecting with an expired one. auth cannot be combined with sessionBootstrap; in the unified client it is configured once, under cluster.
  • Subprotocol offer (client-core/_qwp/_core/durable-ack.ts, connectQwpBrowserEndpoint). With auth, the offer is the caller's protocols, then durable ACK when requested or questdb.qwp.v1 otherwise, then questdb.qwp.authorization.<credential>: the unpadded base64url encoding of the Authorization value. The padded base64 used for Basic cannot be offered, because +, /, and = are not token characters. The query path now builds its offer the same way instead of passing options.protocols through.
  • Credential echo. A server that selects the credential subprotocol has copied the secret into its 101 response. QuestDB never does, so the client fails the connection with a non-retryable QwpUpgradeError (capability-mismatch, tryNextEndpoint: false) and does not walk the endpoint list.
  • Failures. A provider that throws fails the attempt with a QwpUpgradeError of kind authentication whose cause is the thrown value, without trying the remaining endpoints; reconnects retry it unless it carries retryable: false. An invalid credential, fixed or returned by a provider, is rejected before any socket opens; bearer tokens must be visible ASCII. No error quotes the credential.

Behavior changes and tradeoffs

  • Without auth nothing on the wire changes. questdb.qwp.v1 is offered only alongside a credential: a browser fails a handshake whose response selects none of its offers, so offering it unconditionally would break servers that predate it.
  • With auth, a questdb.qwp.authorization.* token among the caller's protocols is rejected, because QuestDB refuses an offer carrying two credentials.
  • A non-object sessionBootstrap.authentication now fails with a TypeError that names the option, rather than one from reading type of undefined. The other sessionBootstrap messages are unchanged.
  • Public API: the auth option and the QwpBrowserAuthProvider and QwpBrowserAuthContext types. Runtime exports are unchanged; the _core barrel now names the durable-ACK exports so the credential helpers stay internal.
  • The echo error reuses the capability-mismatch kind rather than adding a QWP_UPGRADE_ERROR_KIND member.

Tests

  • session.test.ts: offer contents, decoded by QuestDB's rules, for bearer, Basic, non-ASCII Basic, durable ACK, and the query path; the provider called on every connect, failover, and reconnect attempt; the connect deadline and session close aborting a pending provider; provider failures and invalid credentials; conflicting options at every entry point; cluster sharing and per-side overrides; credential echo on ingress and egress. core.test.ts covers the offer builder.
  • browser.e2e.ts, in real Chromium: a cross-origin page authenticates ingress and egress through a provider with a JWT-sized token, and a server that echoes the credential is refused.
  • Manually, against a server built from feat(qwp): allow authenticated browser WebSockets questdb#7683 (be8556a) with Basic authentication and the page's origin allow-listed, driven by real Chromium: a pooled client wrote rows and queried them back; durable ACK plus a credential passed the credential gate; a wrong password, an unlisted origin, and a missing credential got 401; the 101 named only questdb.qwp.v1 and set no cookie; the credential never reached the server log; and a reconnect whose first attempt offered an expired credential (401) recovered on the provider's next call.
  • Every CONTRIBUTING.md gate passes locally. docs/ is regenerated in the same commit.

Also included

  • test(qwp): wait for the ACK watermark in the deferred-recovery test fixes a CI flake unrelated to this feature, seen on main (run 36466605724, Node 20). retires a wholly deferred recovered transaction without replaying it checked for .ack-watermark with a single readdir, but background maintenance deletes the watermark only after the segment files the test waited for, and after a directory sync. The test now waits for it; a 100 ms delay after that sync reproduced the failure deterministically and no longer fails it. Test-only; the store is unchanged.
  • test: retry integration queries that race QuestDB table creation fixes a second CI flake unrelated to this feature, seen on main (run 36716864105, Node 20). can ingest data via TCP and run queries got table does not exist [table=test_tcp] from QuestDB 5 ms after tables() had listed the table: QuestDB lists a new table there before its name resolves for queries, and ILP over TCP has no acknowledgement to wait for. runSelect() now treats that one answer as "not yet"; any other error still fails at once, now with the server's message. This covers the three TCP integration tests. Test-only.

…redential

A browser cannot set Authorization on a WebSocket upgrade, and QuestDB
ignores cookies on a cross-origin QWP upgrade, so a web application
served from another origin had no way to authenticate. QuestDB now
accepts a credential in the upgrade's subprotocol offer from origins
listed in qwp.browser.allowed.origins (questdb/questdb#7683,
questdb/questdb-enterprise#1246).

The new `auth` option on QwpBrowserWebSocketOptions and
QwpBrowserClusterOptions takes a fixed credential, or a function the
client calls before every connect, reconnect, and failover attempt. The
function form lets a long-lived session pick up a refreshed OIDC access
token rather than reconnect with an expired one. `auth` cannot be
combined with sessionBootstrap.

The credential travels as questdb.qwp.authorization.<credential>, the
unpadded base64url encoding of its Authorization value: `+`, `/`, and
`=` are not token characters, so the padded base64 used for Basic
cannot be offered. It is paired with the dialect QuestDB selects --
durable ACK when requested, questdb.qwp.v1 otherwise -- because QuestDB
refuses a credential offered without one. The query path now builds its
offer the same way instead of passing protocols through. Without `auth`
the offer is unchanged: a browser fails a handshake whose response
selects none of its offers, so adding questdb.qwp.v1 would break older
servers.

A server that selects the credential has echoed the secret in its 101
response. QuestDB never does, so the client treats it as a server
defect and fails the connection without retrying or walking the
endpoint list.
glasstiger and others added 3 commits September 30, 2026 13:46
"retires a wholly deferred recovered transaction without replaying it"
waited for the journal's .sfa segments to disappear and then checked for
.ack-watermark with a single readdir. The store removes both, but not
together: background maintenance unlinks the segments first and deletes
the watermark only once that deletion is durable, after an ownership
check and a directory sync. A check that landed between the two found the
watermark still present, as seen on CI (Node 20). A 100ms delay after
that sync reproduces the failure deterministically.

Wait for the watermark the way the test already waits for the segments.
The store's ordering is deliberate -- the watermark is what stops a crash
in that window from recovering the discarded frames -- so it is unchanged.
"can ingest data via TCP and run queries" failed on main (Node 20) with
a 400 from its first query. The container log shows why: QuestDB
answered "table does not exist [table=test_tcp]" 5ms after tables() had
listed the table. ILP over TCP has no acknowledgement, so the test waits
for the table to appear in tables(), but QuestDB lists a new table there
before its name resolves for queries: registerName() hydrates the
metadata cache that tables() reads, syncs the name to the table
registry, and only then makes the name queryable. A query in between
gets "table does not exist", and query() failed on any non-200 without
retrying.

runSelect() now treats that one answer as "not yet" and keeps polling,
reporting the last such error if it times out. Any other non-200 still
fails at once, now with the server's error message, which the job log
lacked. This covers the three TCP tests that share the pattern; the HTTP
tests are not exposed, because an HTTP flush resolves only after table
creation has completed.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Level-3 review verdict: approve (reviewed 3c4515d052af60b1aa9704966c17d69462521919). No admitted findings: Critical 0, Moderate 0, Minor 0; in-diff 0, out-of-diff 0.

Test gate: passed; admitted coverage gaps: 0. Full Vitest suite: 1,141 passed (using the unchanged interop submodule fixture in a disposable worktree). Browser E2E: 10 passed; dist tests: 39 passed. Typechecks, ESLint, formatting, and package checks passed.

The browser E2E server is a fixture, not the tandem QuestDB server; live server interop was not run in this review. The credential subprotocol is base64url-encoded, not encrypted: use wss:. Submodules: none changed.

@glasstiger

Copy link
Copy Markdown
Collaborator Author

Level-3 review: approve

Reviewed 3c4515d052af60b1aa9704966c17d69462521919.

  • Findings: Critical 0, Moderate 0, Minor 0; in-diff 0, out-of-diff 0.
  • Test gate: passed; admitted coverage gaps: 0.
  • Validation: 1,141 suite tests, 39 distribution tests, and 10 Chromium tests passed. Typechecks, lint, formatting, and package checks passed. Additional probes passed for late provider completion, cancellation, terminal reconnect errors, unchanged bootstrap encoding, and the integration-query retry helper.
  • Regressions: none identified.
  • Tradeoff: authentication requires supporting server configuration; use wss: because credentials are encoded, not encrypted.
  • Limitation: browser authentication tested against fixtures, not the live tandem QuestDB server.
  • Submodules: none changed.

Validation ran in disposable worktrees, which were removed afterward. The primary working tree was unchanged.

@glasstiger

Copy link
Copy Markdown
Collaborator Author

Level-3 review: approve

Reviewed 3c4515d052af60b1aa9704966c17d69462521919 against base a65813048a30dd9e3bdcbc12a6c134a460182128.

  • Findings: Critical 0, Moderate 0, Minor 0; in-diff 0, out-of-diff 0.
  • Test gate: passed; admitted coverage gaps: 0.
  • Regressions: none identified.
  • Submodules: none changed.

What was checked

  • No change without auth. The subprotocol offer and request URL were recorded for every browser entry point (raw, ingress session, egress, pooled client) across 7 protocols shapes and 3 requestDurableAck values. All 84 combinations are byte-identical between base and head.
  • Wire contract matches the tandem server. Checked against feat(qwp): allow authenticated browser WebSockets questdb#7683 at e6cdd6a (QwpBrowserAuthorization.decode and both selectBrowserWebSocketProtocol methods):
    • the prefix and the unpadded base64url encoding agree;
    • the server accepts exactly one credential, and the client rejects a second one in protocols;
    • the decoded value must be printable ASCII with no leading or trailing space, which both bearer and Basic values satisfy;
    • ingress selects durable ACK first, then questdb.qwp.v1; egress selects only questdb.qwp.v1; the server never selects the credential.
  • Lifecycle. The provider's signal aborts on the connect deadline, on ingress and egress session close during a reconnect, and on pooled-client close. A provider that resolves late opens no socket.
  • Public types. The documented auth: async ({ signal }) => … examples type-check for consumers on TypeScript 5 (ESM and CJS) and TypeScript 4.9. The runtime export lists of both packages are unchanged.
  • Flake fixes. The explanations match the code: replay-store maintenance removes .ack-watermark only after the segment deletions and a directory sync, and runSelect() retries only a 400 "table does not exist". Neither change can hide a genuine failure; both still fail at their timeouts.

Validation (at head, in disposable worktrees)

Gate Result
typecheck, typecheck:qwp-browser, typecheck:test pass
eslint, format:check pass
Full Vitest suite, including the containerized integration tests 1,141 passed
test:dist 39 passed
test:qwp-browser (Chromium) 10 passed
typecheck:dist (TS 5 ESM/CJS, TS 4.9 legacy) pass
check:packages pass

Tradeoffs (declared in the PR)

  • The credential is base64url-encoded, not encrypted, so it needs wss:.
  • questdb.qwp.v1 is offered only alongside a credential, so servers that predate it are unaffected.
  • The echo error reuses the capability-mismatch kind.

Limitations

  • Not run against a live QuestDB built from the tandem PR; the server rules were checked by reading its source.
  • The browser tests cover Chromium only. How Firefox and Safari handle a long subprotocol offer is unverified.

@glasstiger

Copy link
Copy Markdown
Collaborator Author

Follow-up: live interop against QuestDB Enterprise

This covers the limitation noted in the earlier review: the client was not yet tested against a live server. The browser client at 3c4515d now works end to end against a live Enterprise server built from questdb/questdb-enterprise#1246 (fa3a4013e, with the questdb submodule at questdb/questdb#7683 e6cdd6a). All 12 scenarios behaved as expected.

Setup

  • Server: the production EntServerMain, with qwp.browser.allowed.origins=http://127.0.0.1:18080 and ACL on.
    • The test user alice could only connect over HTTP and read and write one table, and had a REST token.
    • The final run used a replication primary with a filesystem object store, so durable ACK could be tested for real.
  • Client: the browser bundle built from 3c4515d, driven in headless Chromium through Playwright. The test page was served from a different port than QuestDB, so every WebSocket upgrade was cross-origin. Chrome's network events recorded each upgrade request and response.

Results

# Scenario Outcome
S1 Fixed Basic credential (admin), pooled client, write then query Both upgrades accepted with questdb.qwp.v1; 5/5 rows read back
S2 alice's REST token from an async provider Provider called twice (write and query connections); 5/5 rows
S3 alice with Basic, reading, then touching a table she has no grants on current_user() returns alice; SELECT and INSERT on that table both return "Access denied for alice"
S4 Provider returns an expired token on reconnect That attempt got 401, the next provider call recovered, and 6/6 rows arrived
S5 Durable ACK with alice's token Server selected questdb.qwp.durable-ack.v1; durable flush confirmed in 409 ms; the table's data files appeared in the object store
S6 Wrong password 401
S7 No credential, from the allowed origin 401
S8 Valid credential, from an origin not on the allowlist 401
S9 auth from QuestDB's own origin, which is not on the allowlist 401, as the docs say
S10 Same-origin sessionBootstrap (the existing cookie path) Still works; no subprotocol offered; cookie sent
S11–S12 4 KB and 16 KB bearer tokens Server read them and returned 401, so there is no header-size problem

Checks that held in every scenario

  • The server always selected questdb.qwp.v1 or durable ACK, never the credential.
  • No upgrade response set a cookie, and no cookie was sent on a cross-origin upgrade.
  • The server log never contained any credential form: tokens, passwords, the Basic values, the encoded subprotocol values, or the subprotocol prefix. Each rejection was logged with a reason.

Observations

  • In the browser, every 401 appears as a QwpUpgradeError of kind opaque, because the browser hides the response. The real reason is only in the server log. This is inherent to browser WebSockets.
  • An Enterprise-side quirk, unrelated to the credential feature: in an earlier run on a standalone server (no replication), the server accepted the durable-ACK request and said durable ACK was available. No durable ACK then arrived, and the flush timed out after 10 s. That may be expected without replication, but it may be worth checking whether a standalone server should advertise it at all.

Not tested

OIDC tokens (not configured), wss:/TLS (plain ws: on localhost), and Firefox and Safari.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant