Skip to content

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

Merged
bluestreak01 merged 4 commits into
mainfrom
ia_browser_client
Oct 9, 2026
Merged

bluestreak01 merged 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.

@bluestreak01 bluestreak01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Verdict: approve with comments. No Critical findings; one Moderate documentation issue below. Reviewed at level 3 (as requested): $BASE a658130 (current origin/main) against $HEAD 3c4515d.

PR description: follows the conventions. Commit subjects are Conventional Commits, the user impact is clear, the tandem server PRs are linked, and the behaviour changes and tradeoffs are spelled out. The two test-only flake fixes are disclosed, and I reproduced both. No supported input changes a published API's errors or wire bytes, so no ! marker is needed.

Submodules: none.

Critical

None.

Moderate

M1. Root README.md and QWP.md keep cross-origin cookie advice that the PR's new text contradicts

  • Problem: two docs still recommend credentialed CORS for cross-origin cookie auth.
  • Net impact: anyone setting up a cross-origin browser deployment from the root docs builds one that cannot authenticate.
  • Evidence: read directly from the docs at 3c4515d; an independent falsifier confirmed it.

Where it is:

  • Stale sentences:
    • README.md:424-427: "The REST and WebSocket routes should be served from the same browser origin (or configured with credentialed CORS)…"
    • QWP.md:974-975: "…or correctly configured credentialed CORS and cookie attributes."
  • New text in the same sections that contradicts them:
    • README.md:430-432: "QuestDB ignores cookies on such a cross-origin upgrade."
    • QWP.md:1002-1003: "…so session bootstrap cannot authenticate it."

Why it is wrong:

  • The PR already removed this same sentence from packages/browser-client/README.md (base lines 208-210, now lines 224-227) and replaced it with "use auth instead". The two root documents are now inconsistent with that file and with themselves.
  • The advice cannot work on either server:
    • The base server rejects every cross-origin QWP upgrade.
    • With questdb#7683, a cross-origin upgrade ignores cookies and returns 401. Its own test, testQwpBrowserListedOriginIgnoresAmbientCredentials, asserts this.
    • CORS never applies to WebSocket upgrades, so the "credentialed CORS" clause cannot rescue the WebSocket route.

Classification: in-diff. The sentences themselves predate the PR, but the contradiction comes from the text the PR added, and the PR fixed only one of the three copies.

Suggested fix:

  1. Replace both clauses with the browser README's wording: serve /exec, /write/v4 and /read/v1 from the application's origin or through a same-origin proxy, and use auth for an application on another origin.
  2. Run pnpm run docs so docs/media/QWP.md stays in sync. test/docs-reference.test.ts enforces that.

Coverage map

Test gate: passes. Admitted coverage gaps: 0.

  • Mutation testing: I made 17 deliberate breakages to the new auth paths. Each was caught by at least one new test. They covered:
    • removing the echo check;
    • the retry and endpoint-walk flags;
    • always adding questdb.qwp.v1;
    • offering durable ACK on egress;
    • padded base64url;
    • skipping each validation call;
    • caching the provider result;
    • passing the provider an unrelated signal.
  • Encoding: I compared 600 randomised Basic and bearer credentials, including multi-byte UTF-8, against Node's base64 and the server's decoding rules. There were no mismatches. The bootstrapQwpBrowserSession header is byte-identical to base.
  • Egress reconnect: there is no dedicated test, but I probed the head build. A provider that fails once is retried and the session reconnects. A provider that returns an invalid credential stops the reconnect loop.
  • Flake fixes:
    • reconnect.test.ts: I added a 100 ms delay before .ack-watermark is deleted. The base test then fails and the head test passes.
    • sender.integration.test.ts: 10 of 10 tests pass against a QuestDB container.

Summary

All the CONTRIBUTING.md checks pass at head:

  • eslint, format:check and the source and test type checks;
  • the bench type check and lint;
  • the unit suite: 1119 pass, and the one failure is environmental (the questdb-client-test submodule wasn't initialised in my scratch checkout; that ILP interop test is unrelated to this PR);
  • test:dist and typecheck:dist, including TypeScript 4.9;
  • check:packages;
  • the real-Chromium browser e2e suite, 10 of 10.

Behaviour for existing users: unchanged.

  • Runtime exports: both packages export the same names in ESM and CJS at base and head.
  • New type exports: QwpBrowserAuthContext and QwpBrowserAuthProvider, both browser-only.
  • No auth option: the subprotocol offer is the same as on base.

Not a regression: with failoverUrls, a token provider that hangs past connectTimeoutMs is called once per endpoint, and the failure reports timeouts rather than an auth problem. This is documented, the PR tests it deliberately, and a hung sessionBootstrap behaves the same way on base.

Findings:

  • Admitted: 1 in-diff, 0 out-of-diff breakage.
  • Severity: 0 Critical, 1 Moderate, 0 Minor.

Residual risk: I checked the client against the tandem server diff at questdb#7683 head 8da9ba8, which is still unmerged. If that PR changes how the server chooses the subprotocol or decodes the credential before it merges, recheck the offer builder.

@bluestreak01
bluestreak01 merged commit 87cd627 into main Oct 9, 2026
7 checks passed
glasstiger added a commit that referenced this pull request Oct 10, 2026
Main's browser credential authentication (#68) changed
packages/browser-client/src/index.ts, which on this branch only
re-exports; the implementation moved to qwp.ts, so the change is merged
there instead. auth is cluster-owned like url, failoverUrls and
sessionBootstrap, and main's credential tests use this branch's
single-options-object API. docs/ keeps this branch's pages until the
next commit regenerates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants