From 5879911585979e02e597d32113540f690385a93d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 19 Oct 2026 23:46:51 +0100 Subject: [PATCH 01/31] fix: fix Web Console hanging or signing out when SSO tokens expire Extracted from questdb/ui#603: the console half of questdb/questdb#7799 and questdb/questdb-enterprise#1267. Those PRs stop serving a request from the qdb_session cookie when its Authorization header is rejected, so an expired or stale bearer now gets a 401 and closes the session instead of being masked. - Refresh an ID-token bearer (acl.oidc.groups.encoded.in.token=true) by the ID token's exp claim rather than the access token's expires_in, and report a missing ID token instead of sending "Bearer undefined". - Read the bearer at send time for SQL, validation, CSV check/import and preference requests, and never let stored REST/Basic credentials replace an SSO bearer. - Coordinate token refresh in one bounded, cancellable place: queries wait up to 10s, a rotating refresh may still land within 60s, transient failures back off without logging out, and an expired bearer is never sent after a failed renewal (fixes the refresh deadlock that froze every query). - Recover from a rejected bearer: refresh and resend once (a 401 executed no SQL), ignore 401s answered for an older bearer, and log out only when the current bearer is rejected. - Show a renewal warning and a recovery view with Retry/Sign in again instead of a blank console, and report sign-in failures. - Share one client between the console and telemetry so telemetry never sends a stale bearer, and mark read-only requests as background so logout warnings only count statements and writes. - Dispatch the Enterprise Web Console e2e lane added by questdb-enterprise#1267, send the stored REST token in e2e execQuery, and extend the unit and stubbed browser auth tests. --- .github/workflows/enterprise_e2e.yml | 79 + .gitleaksignore | 3 + e2e/README.md | 53 +- e2e/commands.js | 19 +- e2e/tests/auth/auth.spec.js | 851 +++++++++- e2e/tests/auth/unauthorized.spec.js | 72 + e2e/tests/enterprise/oidc.spec.js | 246 ++- package.json | 2 + src/components/ConnectionErrorView.tsx | 77 + src/components/Toast/index.tsx | 1 + src/components/TopBar/toolbar.tsx | 43 +- .../catalogSource/requestDeadline.test.ts | 80 + src/hooks/catalogSource/requestDeadline.ts | 34 + .../catalogSource/useCatalogSource.test.tsx | 256 +++ src/hooks/catalogSource/useCatalogSource.ts | 43 +- src/hooks/useQueryExecution.ts | 3 +- src/index.tsx | 2 +- src/js/console/quick-vis.ts | 1 + src/modules/EventBus/types.ts | 1 + src/modules/OAuth2/authSessionState.ts | 86 + src/modules/OAuth2/handleUnauthorized.test.ts | 80 + src/modules/OAuth2/handleUnauthorized.ts | 32 + src/modules/OAuth2/session.ts | 53 + src/modules/OAuth2/ssoAuthState.ts | 23 - src/modules/OAuth2/types.ts | 33 +- src/modules/OAuth2/utils.test.ts | 596 +++++++ src/modules/OAuth2/utils.ts | 259 ++- src/modules/OAuth2/views/login.test.tsx | 166 ++ src/modules/OAuth2/views/login.tsx | 58 +- .../views/refreshFailedWarning.test.tsx | 121 ++ .../OAuth2/views/refreshFailedWarning.tsx | 50 + src/providers/AuthProvider.test.tsx | 1444 +++++++++++++++++ src/providers/AuthProvider.tsx | 447 +++-- .../QuestProvider/AuthCheckRecovery.tsx | 111 ++ src/providers/QuestProvider/index.test.tsx | 586 +++++++ src/providers/QuestProvider/index.tsx | 168 +- src/scenes/Editor/Metrics/metric.tsx | 1 + .../cellRefresh/cellRefreshEngine.test.ts | 6 + .../Notebook/cellRefresh/cellRefreshEngine.ts | 8 +- .../Editor/Notebook/notebookUtils.test.ts | 17 + src/scenes/Editor/Notebook/notebookUtils.ts | 29 +- src/scenes/Import/ImportCSVFiles/index.tsx | 9 +- src/scenes/Import/ImportCSVFiles/upload.tsx | 1 + .../Result/ResultChart/useChartQuery.ts | 1 + src/scenes/Result/index.tsx | 1 + src/scenes/Schema/SuspensionDialog/index.tsx | 1 + src/scenes/Schema/index.tsx | 6 +- src/store/Telemetry/epics.test.ts | 233 ++- src/store/Telemetry/epics.ts | 84 +- src/store/notebook.ts | 8 +- src/utils/executeSingleRaw.test.ts | 16 + src/utils/executeSingleRaw.ts | 26 +- src/utils/localStorage/types.ts | 2 + src/utils/questdb/cancellation.ts | 8 + src/utils/questdb/client.auth.test.ts | 1065 ++++++++++++ .../questdb/client.backgroundAuth.test.ts | 74 + src/utils/questdb/client.nonSqlAuth.test.ts | 686 ++++++++ src/utils/questdb/client.test.ts | 649 +++++++- src/utils/questdb/client.ts | 625 +++++-- src/utils/questdb/queryClient.ts | 10 + src/utils/questdb/queryExecution.ts | 5 + .../queryExecutionManager.session.test.ts | 51 + src/utils/questdb/queryExecutionManager.ts | 19 + src/utils/questdb/tokenRefresh.ts | 225 +++ src/utils/questdb/types.ts | 40 +- src/utils/telemetryQueries.test.ts | 75 + src/utils/telemetryQueries.ts | 17 + src/utils/tools/runQuery.test.ts | 69 + src/utils/tools/runQuery.ts | 26 +- src/utils/withDeadline.ts | 27 + yarn.lock | 52 +- 71 files changed, 9773 insertions(+), 578 deletions(-) create mode 100644 .github/workflows/enterprise_e2e.yml create mode 100644 e2e/tests/auth/unauthorized.spec.js create mode 100644 src/components/ConnectionErrorView.tsx create mode 100644 src/hooks/catalogSource/requestDeadline.test.ts create mode 100644 src/hooks/catalogSource/requestDeadline.ts create mode 100644 src/hooks/catalogSource/useCatalogSource.test.tsx create mode 100644 src/modules/OAuth2/authSessionState.ts create mode 100644 src/modules/OAuth2/handleUnauthorized.test.ts create mode 100644 src/modules/OAuth2/handleUnauthorized.ts create mode 100644 src/modules/OAuth2/session.ts delete mode 100644 src/modules/OAuth2/ssoAuthState.ts create mode 100644 src/modules/OAuth2/utils.test.ts create mode 100644 src/modules/OAuth2/views/login.test.tsx create mode 100644 src/modules/OAuth2/views/refreshFailedWarning.test.tsx create mode 100644 src/modules/OAuth2/views/refreshFailedWarning.tsx create mode 100644 src/providers/AuthProvider.test.tsx create mode 100644 src/providers/QuestProvider/AuthCheckRecovery.tsx create mode 100644 src/providers/QuestProvider/index.test.tsx create mode 100644 src/utils/questdb/cancellation.ts create mode 100644 src/utils/questdb/client.auth.test.ts create mode 100644 src/utils/questdb/client.backgroundAuth.test.ts create mode 100644 src/utils/questdb/client.nonSqlAuth.test.ts create mode 100644 src/utils/questdb/queryClient.ts create mode 100644 src/utils/questdb/queryExecution.ts create mode 100644 src/utils/questdb/queryExecutionManager.session.test.ts create mode 100644 src/utils/questdb/tokenRefresh.ts create mode 100644 src/utils/telemetryQueries.test.ts create mode 100644 src/utils/telemetryQueries.ts create mode 100644 src/utils/withDeadline.ts diff --git a/.github/workflows/enterprise_e2e.yml b/.github/workflows/enterprise_e2e.yml new file mode 100644 index 000000000..0116b80f7 --- /dev/null +++ b/.github/workflows/enterprise_e2e.yml @@ -0,0 +1,79 @@ +name: Enterprise E2E + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: enterprise-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + dispatch: + name: Dispatch Enterprise Web Console E2E + runs-on: ubuntu-latest + timeout-minutes: 5 + # Mirrors the TypeScript client lane. Fork PRs never receive dispatch + # credentials; live EE tests/builds execute only in the private repository. + if: >- + vars.ENTERPRISE_E2E_ENABLED == 'true' && (github.event_name != + 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + steps: + - name: Queue private Enterprise Web Console pipeline + env: + ENT_DISPATCH_PAT: ${{ secrets.ENT_DISPATCH_PAT }} + UI_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + UI_PR_NUMBER: ${{ github.event.pull_request.number || '' }} + UI_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -euo pipefail + test -n "${ENT_DISPATCH_PAT:-}" || { + echo "ENT_DISPATCH_PAT is not configured" >&2 + exit 1 + } + [[ "$UI_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "Expected an immutable UI commit SHA" >&2 + exit 1 + } + ORG_URL="https://dev.azure.com/questdb/" + PROJECT="questdb-enterprise" + PIPELINE_NAME="build-and-test-e2e-web-console" + azure_failure() { + if [ -z "$2" ]; then + echo "Azure DevOps $1 failed without a response body; see curl's error above." >&2 + else + echo "Azure DevOps rejected $1: $( + jq -er '.message | strings | select(length > 0)' <<<"$2" 2>/dev/null || + head -c 500 <<<"$2" + )" >&2 + fi + } + if ! PIPELINES=$(curl --fail-with-body -sS -u ":${ENT_DISPATCH_PAT}" \ + "${ORG_URL}${PROJECT}/_apis/pipelines?api-version=7.0"); then + azure_failure "pipeline lookup" "$PIPELINES" + exit 1 + fi + PIPELINE_ID=$(jq -r --arg name "$PIPELINE_NAME" \ + '.value[] | select(.name == $name) | .id' <<<"$PIPELINES" | head -1) + test -n "$PIPELINE_ID" && [ "$PIPELINE_ID" != "null" ] || { + echo "Enterprise pipeline '$PIPELINE_NAME' is not registered" >&2 + exit 1 + } + BODY=$(jq -n --arg commit "$UI_COMMIT" --arg pr "$UI_PR_NUMBER" --arg branch "$UI_BRANCH" ' + {templateParameters: ( + {webConsoleCommit: $commit, clientBranch: $branch} + + (if $pr == "" then {} else {webConsolePrNumber: $pr} end) + )}') + if ! RESPONSE=$(curl --fail-with-body -sS -u ":${ENT_DISPATCH_PAT}" \ + -H "Content-Type: application/json" -X POST -d "$BODY" \ + "${ORG_URL}${PROJECT}/_apis/pipelines/${PIPELINE_ID}/runs?api-version=7.0"); then + azure_failure "dispatch" "$RESPONSE" + exit 1 + fi + echo "Enterprise E2E queued: $(jq -r '._links.web.href' <<<"$RESPONSE")" diff --git a/.gitleaksignore b/.gitleaksignore index 530b0de9e..eac94480f 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -4,3 +4,6 @@ c506a468bcc77f790cfbb2d412fb9674a47db7cb:e2e/tests/console/mcpBridgePermissions.spec.js:generic-api-key:9 c506a468bcc77f790cfbb2d412fb9674a47db7cb:src/utils/mcp/consumePendingPair.test.ts:generic-api-key:27 5012b6fa7b20dd00bdc0c1bf188d8e3b48d36c0a:e2e/utils/mcpFakeWebSocket.js:generic-api-key:5 +0f4d4ac8c7ec82dec1b332e4057aa0198200d5e2:e2e/tests/enterprise/oidc.spec.js:generic-api-key:102 +0f4d4ac8c7ec82dec1b332e4057aa0198200d5e2:e2e/tests/enterprise/oidc.spec.js:generic-api-key:103 +0f4d4ac8c7ec82dec1b332e4057aa0198200d5e2:e2e/tests/enterprise/oidc.spec.js:generic-api-key:104 diff --git a/e2e/README.md b/e2e/README.md index 50ed02fcf..3f2f76255 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -2,14 +2,17 @@ To run browser tests in a local dev environment: -* make sure `localhost:9999` points to QuestDB Web Console -* disable telemetry in QuestDB Web Console. Use env variable `QDB_TELEMETRY_ENABLED=false` - if running with docker, you can pass the variable like this: +- make sure `localhost:9999` points to QuestDB Web Console +- disable telemetry in QuestDB Web Console. Use env variable + `QDB_TELEMETRY_ENABLED=false` if running with docker, you can pass the + variable like this: + ``` docker run -e QDB_TELEMETRY_ENABLED=false -p 9000:9000 -p 9009:9009 questdb/questdb:latest ``` -* run `cypress` through `yarn`: +- run `cypress` through `yarn`: + ``` yarn test:e2e # console tests yarn test:e2e:auth # auth tests @@ -21,3 +24,45 @@ If you want to interact with cypress, you can start it like so: ``` yarn cypress open ``` + +## Enterprise integration lane + +The ordinary CI workflow always runs `e2e/tests/auth/*.spec.js`. These use +stubbed HTTP responses and cover token parsing (including string/zero expiry), +JSON 429/503 refresh failures, credential precedence, error focus/dismissal and +retrying the initial SQL check without requiring a licensed database. + +Live EE builds and authentication fixtures belong in the **private Enterprise +repository**, following its JavaScript-client cross-repository lane. This +repository's `.github/workflows/enterprise_e2e.yml` only dispatches Azure; it +neither checks out Enterprise source nor downloads an EE binary. + +One-time maintainer setup: + +1. Register Enterprise's `ci/build-and-test-e2e-web-console.yaml` as the Azure + pipeline `build-and-test-e2e-web-console` in the `questdb-enterprise` + project. Authorize its existing `github-public-read` variable group and + ensure the private lane's `GITHUB_TOKEN` can post commit statuses to + `questdb/ui`. +2. Give the UI repository the Actions secret `ENT_DISPATCH_PAT`, scoped to Azure + Build (Read & execute) with permission to queue that pipeline. It needs no + access to the private GitHub repository or to Enterprise binaries. +3. Set the UI Actions variable `ENTERPRISE_E2E_ENABLED=true`. + +Same-repository PRs, main pushes and manual runs dispatch the exact UI commit +SHA. Fork PRs never receive the PAT. Enterprise matches the UI branch when an EE +branch of the same name exists (otherwise uses main), builds EE with its +existing private test harness, and builds the public UI checkout at that SHA. +The private pytest driver runs `yarn test:e2e:enterprise` against a fresh EE +server, validates that invalid credentials are rejected, and supplies the +server-side userinfo fixture. Cypress still stubs browser code/token exchange. +The result is posted to the tested SHA as `enterprise-e2e-web-console`, even if +the PR head moves while tests run. A successful dispatch is **not** a passed +integration run: check that separate status. + +For local runs, follow the Enterprise e2e README and set +`QDB_WEB_CONSOLE_DIR=/absolute/path/to/built/ui`, then run +`pytest -m web_console` in `questdb-ent/e2e`. The UI checkout needs +`yarn install --immutable`, `yarn build`, and a working Cypress installation +(`yarn cypress verify`). Ports 9000 and 9999 must be free; the harness owns and +tears down both services. diff --git a/e2e/commands.js b/e2e/commands.js index 40c020d64..ccd355a83 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -581,13 +581,18 @@ Cypress.Commands.add("selectQueries", (selection) => { }) Cypress.Commands.add("execQuery", (query) => { - const authHeader = localStorage.getItem("basic.auth.header") - cy.request({ - method: "GET", - url: `${baseUrl}/exec?query=${encodeURIComponent(query)};`, - headers: { - Authorization: authHeader, - }, + return cy.window().then(({ localStorage }) => { + // EE password login stores a REST token, OSS stores a Basic header. + // Read the application's storage at request time, not the runner window. + const restToken = localStorage.getItem("rest.token") + const authorization = restToken + ? `Bearer ${restToken}` + : localStorage.getItem("basic.auth.header") + return cy.request({ + method: "GET", + url: `${baseUrl}/exec?query=${encodeURIComponent(query)};`, + headers: authorization ? { Authorization: authorization } : {}, + }) }) }) diff --git a/e2e/tests/auth/auth.spec.js b/e2e/tests/auth/auth.spec.js index cfff7e699..e2d344635 100644 --- a/e2e/tests/auth/auth.spec.js +++ b/e2e/tests/auth/auth.spec.js @@ -1,5 +1,7 @@ /// +const { seedNotebookOnboarding } = require("../../utils") + const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" const baseUrl = `http://localhost:9999${contextPath}` @@ -19,10 +21,30 @@ const startedFlow = { codeVerifier: "verifier-we-generated", } -const visitCallback = (query, flow) => +const queryResult = { + query: "select 2", + columns: [{ name: "column", type: "INT" }], + dataset: [[2]], + count: 1, +} + +const stubConsoleRequests = () => { + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*` }, + { statusCode: 200, body: { columns: [], dataset: [], count: 0 } }, + ) + cy.intercept({ method: "GET", url: `${baseUrl}/warnings*` }, []) + cy.intercept({ method: "GET", url: `${baseUrl}/chk*` }, { status: "Exists" }) +} + +const visitCallback = (query, flow, credentials = {}) => cy.visit(`${baseUrl}${query}`, { onBeforeLoad(win) { win.localStorage.clear() + seedNotebookOnboarding(win) + Object.entries(credentials).forEach(([key, value]) => { + win.localStorage.setItem(key, value) + }) if (flow) { win.localStorage.setItem(STORE_KEY.oauthState, flow.state) win.localStorage.setItem(STORE_KEY.pkceCodeVerifier, flow.codeVerifier) @@ -102,6 +124,7 @@ describe("Auth - OIDC", () => { describe("Auth - Basic", () => { before(() => { + stubConsoleRequests() interceptSettings({ config: { "release.type": "EE", @@ -256,23 +279,7 @@ describe("Auth - OIDC callback validation", () => { }, ).as("tokenExchange") - cy.intercept( - { method: "GET", url: `${baseUrl}/exec*` }, - { - statusCode: 200, - body: { - query: "select 2", - columns: [{ name: "column", type: "INT" }], - dataset: [[2]], - count: 1, - }, - }, - ) - cy.intercept({ method: "GET", url: `${baseUrl}/warnings*` }, []) - cy.intercept( - { method: "GET", url: `${baseUrl}/chk*` }, - { status: "Exists" }, - ) + stubConsoleRequests() } const expectNoTokenExchange = () => { @@ -362,3 +369,811 @@ describe("Auth - OIDC callback validation", () => { }) }) }) + +describe("Auth - OIDC sign-in errors", () => { + const tokenUrl = "https://oauth.example.com/token" + // The console's own params, e.g. a shared query, which it saves before the + // redirect to the provider + const returnParams = "query=select+1" + + const visitCallbackWithReturnParams = (query) => + cy.visit(`${baseUrl}${query}`, { + onBeforeLoad(win) { + win.localStorage.clear() + win.sessionStorage.setItem("oauth.return.params", returnParams) + }, + }) + + beforeEach(() => { + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": false, + }, + }) + stubConsoleRequests() + }) + + it("does not resubmit a code that failed to sign in on reload", () => { + // Given a provider that rejects the authorization code + cy.intercept( + { method: "POST", url: tokenUrl }, + { + statusCode: 400, + body: { error: "invalid_grant", error_description: "Code not valid" }, + }, + ).as("tokenExchange") + visitCallbackWithReturnParams("?code=code-from-the-provider") + cy.wait("@settings") + cy.contains("Code not valid").should("be.visible") + + // Then the URL gets back the console's own params instead of the code + cy.location("search").should("eq", `?${returnParams}`) + + // When the page is reloaded + cy.reload() + + // Then the console shows the login page without exchanging the code again + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("Code not valid").should("not.exist") + cy.get("@tokenExchange.all").should("have.length", 1) + }) + + it("does not show a provider error again on reload", () => { + // Given a provider that redirects back with an error + visitCallbackWithReturnParams( + "?error=access_denied&error_description=Sign-in+cancelled", + ) + cy.wait("@settings") + cy.contains("access_denied: Sign-in cancelled").should("be.visible") + + // Then the URL gets back the console's own params instead of the error + cy.location("search").should("eq", `?${returnParams}`) + + // When the page is reloaded + cy.reload() + + // Then the console shows the login page without the old error + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("access_denied: Sign-in cancelled").should("not.exist") + }) + + it("restores focus to the sign-in heading when the error is dismissed", () => { + cy.intercept("POST", tokenUrl, { + statusCode: 400, + body: { error: "invalid_grant" }, + }) + visitCallback("?code=bad-code") + cy.getByDataHook("sign-in-error") + .should("be.focused") + .should(($error) => { + const win = $error[0].ownerDocument.defaultView + const style = win.getComputedStyle($error[0]) + expect(style.outlineStyle).not.to.equal("none") + expect(parseFloat(style.outlineWidth)).to.be.greaterThan(0) + }) + cy.get('[aria-label="Dismiss sign-in error"]').focus().click() + cy.getByDataHook("sign-in-error").should("not.exist") + cy.contains("h2", "Sign in to QuestDB").should("be.focused") + }) + + it("keeps password focus when submitting with an SSO error still visible", () => { + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { session: "true", query: /alter user .* create token/ }, + }, + { statusCode: 401, delay: 1000, body: "Unauthorized" }, + ).as("passwordLogin") + visitCallback("?error=access_denied&error_description=Sign-in+cancelled") + cy.getByDataHook("sign-in-error").should("be.focused") + cy.get('form[name="login"] input[name="username"]').type("admin") + cy.get('form[name="login"] input[name="password"]').type("wrong{enter}") + cy.getByDataHook("sign-in-error").should("not.exist") + cy.get('form[name="login"] input[name="password"]').should("be.focused") + cy.wait("@passwordLogin") + cy.contains("Invalid username or password").should("be.visible") + cy.get('form[name="login"] input[name="password"]').should("be.focused") + }) + + for (const { failure, reply, message } of [ + { + failure: "cannot be reached", + reply: { forceNetworkError: true }, + message: + "Could not reach the identity provider to complete the sign-in. Please try again.", + }, + { + failure: "returns a response that is not JSON", + reply: { statusCode: 502, body: "Bad Gateway" }, + message: + "The identity provider returned an unexpected response (HTTP 502). Please try again.", + }, + ]) { + it(`shows an error, not a blank page, when the token endpoint ${failure}`, () => { + // Given a token endpoint that fails without an OAuth response + cy.intercept({ method: "POST", url: tokenUrl }, reply) + visitCallback("?code=code-from-the-provider") + cy.wait("@settings") + + // Then the console shows the login page with the error + cy.getByDataHook("auth-login").should("be.visible") + cy.contains(message) + .closest("[data-hook='sign-in-error']") + .should("be.focused") + }) + } + + it("shows an error, not a blank page, when the token endpoint does not respond", () => { + // Given a token endpoint that does not respond within the timeout + let requested = false + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + requested = true + req.reply({ delay: 30000, body: {} }) + }) + // Fake only the timeouts, so the test does not wait for 10 seconds + cy.clock(Date.now(), ["setTimeout", "clearTimeout"]) + visitCallback("?code=code-from-the-provider") + cy.wait("@settings") + cy.wrap(null).should(() => expect(requested).to.equal(true)) + + // When the timeout elapses + cy.tick(10000) + + // Then the console shows the login page with the error + cy.getByDataHook("auth-login").should("be.visible") + cy.contains( + "The identity provider did not respond within 10 seconds. Please try again.", + ) + .closest("[data-hook='sign-in-error']") + .should("be.focused") + }) +}) + +describe("Auth - OIDC ID token", () => { + const tokenUrl = "https://oauth.example.com/token" + + // Unsigned: the console only reads the claims, and the stubbed server does + // not validate the token. + const idToken = (subject, lifetimeSeconds) => { + const base64url = (part) => + btoa(JSON.stringify(part)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + const iat = Math.floor(Date.now() / 1000) + return [ + base64url({ alg: "RS256", typ: "JWT" }), + base64url({ sub: subject, iat, exp: iat + lifetimeSeconds }), + "signature", + ].join(".") + } + + // Signs in with SSO to a server that expects the ID token as the bearer. + // The provider returns the ID token given for each grant type, while the + // token response's expires_in describes the access token. + const signIn = ({ + idTokens, + accessTokenLifetime = 3600, + refreshReply, + credentials, + authCheckReply, + }) => { + const grants = [] + + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": true, + }, + }) + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + const grant = new URLSearchParams(req.body).get("grant_type") + grants.push(grant) + if (grant === "refresh_token" && refreshReply) { + req.alias = "refreshFailure" + if (typeof refreshReply === "function") return refreshReply(req) + req.reply(refreshReply) + return + } + req.reply({ + access_token: `${grant}-access-token`, + refresh_token: "refresh-token", + id_token: idTokens[grant], + token_type: "Bearer", + expires_in: accessTokenLifetime, + }) + }) + stubConsoleRequests() + // The first query after sign-in, which waits for a due refresh + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: /^SELECT 1$/ }, + }, + authCheckReply ?? { statusCode: 200, body: queryResult }, + ).as("authCheck") + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*`, query: { session: "true" } }, + { statusCode: 200, body: queryResult }, + ).as("sessionStart") + + visitCallback("?code=code-from-the-provider", undefined, credentials) + cy.wait("@settings") + return grants + } + + const expectMissingIdTokenError = (detail) => { + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("Missing ID token") + .closest("[data-hook='sign-in-error']") + .should("be.focused") + cy.contains("ask your administrator to check the SSO configuration").should( + "be.visible", + ) + cy.contains("Your identity provider did not send the information") + .should("be.visible") + .and("contain", detail) + cy.getEditor().should("not.exist") + } + + for (const { failure, reply } of [ + { failure: "network error", reply: { forceNetworkError: true } }, + { + failure: "non-JSON 502", + reply: { statusCode: 502, body: "Bad Gateway" }, + }, + { + failure: "JSON 503", + reply: { statusCode: 503, body: { error: "temporarily_unavailable" } }, + }, + { + failure: "JSON 429", + reply: { statusCode: 429, body: { error: "rate_limited" } }, + }, + ]) { + it(`keeps the session after a refresh ${failure} and a later SQL 401, then recovers`, () => { + // Freeze only Date so the SQL rejection occurs during refresh backoff; + // real timers still drive requests and notification transitions. + cy.clock(Date.now(), ["Date"]) + const initial = idToken("initial", 20) + signIn({ idTokens: { authorization_code: initial }, refreshReply: reply }) + cy.wait("@refreshFailure") + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${initial}`) + cy.getEditor().should("be.visible") + cy.contains( + "Could not renew your SSO session. Your current token expires at", + { matchCase: false }, + ).should("be.visible") + // A persistent warning has no running auto-close animation. + cy.get("#sso-refresh-failed .Toastify__progress-bar").should( + "not.have.class", + "Toastify__progress-bar--animated", + ) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("expiredBearerQuery") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@expiredBearerQuery") + cy.contains("the request was not sent").should("be.visible") + cy.getEditor().should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.get("#sso-refresh-failed").should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.session.active") + .should("eq", "true") + + const renewed = idToken("renewed", 3600) + cy.intercept("POST", tokenUrl, { + body: { + access_token: "renewed-access-token", + id_token: renewed, + refresh_token: "rotated-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }, + }).as("refreshRecovery") + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 200, body: queryResult }, + ).as("recoveredQuery") + cy.tick(5000) + cy.runLine() + cy.wait("@refreshRecovery") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", `Bearer ${renewed}`) + cy.get("#sso-refresh-failed").should("not.exist") + cy.getByDataHook("auth-login").should("not.exist") + + // Exercise the installed rotated credential through the next real + // AuthProvider -> QuestProvider -> token POST, not just its storage. + const renewedAgain = idToken("renewed-again", 7200) + cy.intercept("POST", tokenUrl, (req) => { + const body = new URLSearchParams(req.body) + expect(body.get("grant_type")).to.eq("refresh_token") + expect(body.get("refresh_token")).to.eq("rotated-refresh-token") + req.reply({ + access_token: "renewed-again-access-token", + id_token: renewedAgain, + refresh_token: "next-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }) + }).as("rotatedRefresh") + cy.clock().then((clock) => clock.setSystemTime(Date.now() + 3600000)) + cy.runLine() + cy.wait("@rotatedRefresh") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", `Bearer ${renewedAgain}`) + cy.getByDataHook("auth-login").should("not.exist") + }) + } + + it("clears the remembered identity before the redirect guard forces sign-in", () => { + // Unknown expiry avoids a renewal on 401: this test targets the redirect + // guard's forced-login path, not refresh recovery or its connection events. + signIn({ + idTokens: { authorization_code: "opaque-id-token" }, + accessTokenLifetime: null, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.getEditor().should("be.visible") + cy.window().then(({ localStorage }) => { + localStorage.setItem("sso.username.test-client", "previous-user") + }) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejectedIdentity") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.window() + .its("localStorage") + .invoke("setItem", "oauth.redirect.count", "5") + cy.runLine() + cy.wait("@rejectedIdentity") + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("button-sso-continue").should("not.exist") + cy.getByDataHook("button-sso-login").should( + "contain.text", + "Single Sign-On (SSO)", + ) + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.test-client") + .should("be.null") + cy.intercept("GET", "https://oauth.example.com/auth*", (req) => { + req.redirect(`${baseUrl}?error=access_denied`) + }).as("forcedSignIn") + cy.getByDataHook("button-sso-login").click() + cy.wait("@forcedSignIn").then(({ request }) => { + expect(new URL(request.url).searchParams.get("prompt")).to.eq("login") + }) + cy.getByDataHook("auth-login").should("be.visible") + }) + + it("hands focus to Login after the warning's sign-in action", () => { + signIn({ + idTokens: { authorization_code: idToken("initial", 20) }, + refreshReply: { + statusCode: 503, + body: { error: "temporarily_unavailable" }, + }, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.wait("@refreshFailure") + cy.get("#sso-refresh-failed").should("be.visible") + cy.contains("#sso-refresh-failed button", "Sign in again").click() + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("sign-in-error") + .should("be.focused") + .and("contain.text", "Your previous session has ended") + cy.get('[aria-label="Dismiss sign-in error"]').click() + cy.contains("h2", "Sign in to QuestDB").should("be.focused") + cy.get('form[name="login"] input[name="username"]') + .type("admin") + .should("be.focused") + }) + + it("shows Login when a pending refresh fails after Settings recovery replaces the providers", () => { + let releaseRefresh + let failSettings = false + cy.clock(Date.now(), ["Date"]) + const grants = signIn({ + idTokens: { authorization_code: idToken("initial", 3600) }, + refreshReply: (req) => + new Promise((resolve) => { + releaseRefresh = () => { + req.reply({ + statusCode: 400, + body: { error: "invalid_grant", error_description: "Revoked" }, + }) + resolve() + } + }), + credentials: { "auto.refresh.tables": "true" }, + }) + cy.getEditor().should("be.visible") + cy.get("@settings").then(({ response }) => { + cy.intercept({ method: "GET", url: `${baseUrl}/settings` }, (req) => { + req.reply( + failSettings + ? { statusCode: 503, body: "Unavailable" } + : { body: response.body }, + ) + }) + }) + cy.clearEditor() + cy.typeQuery("select 42") + cy.tick(3575000) + cy.clickRunIconInLine(1) + cy.wrap(null).should(() => expect(releaseRefresh).to.be.a("function")) + cy.window().then((win) => { + failSettings = true + win.dispatchEvent(new win.Event("focus")) + }) + cy.contains("button", "Retry") + .should("be.visible") + .then(() => { + failSettings = false + }) + .click() + cy.contains("button", "Retry").should("not.exist") + cy.then(() => { + expect( + grants.filter((grant) => grant === "refresh_token"), + ).to.have.length(1) + releaseRefresh() + }) + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("sign-in-error") + .should("be.focused") + .and("contain.text", "Your SSO session has expired") + .and("contain.text", "Revoked Please sign in again.") + cy.getEditor().should("not.exist") + }) + + it("starts telemetry only after the auth check and uses the refreshed SSO bearer", () => { + const initial = idToken("initial", 20) + const refreshed = idToken("refreshed", 3600) + let authRequested = false + let releaseAuth + const telemetryHeaders = [] + signIn({ + idTokens: { authorization_code: initial, refresh_token: refreshed }, + credentials: { "auto.refresh.tables": "false" }, + authCheckReply: (req) => { + authRequested = true + return new Promise((resolve) => { + releaseAuth = () => { + req.reply({ body: queryResult }) + resolve() + } + }) + }, + }) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: /^telemetry_config limit -1$/i }, + }, + (req) => { + telemetryHeaders.push(req.headers.authorization) + req.reply({ body: { columns: [], count: 0, dataset: [] } }) + }, + ).as("telemetrySQL") + cy.wrap(null).should(() => { + expect(authRequested).to.equal(true) + expect(telemetryHeaders).to.deep.equal([]) + }) + cy.getEditor().should("not.exist") + cy.then(() => releaseAuth()) + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${refreshed}`) + cy.wait("@telemetrySQL") + .its("request.headers.authorization") + .should("eq", `Bearer ${refreshed}`) + cy.getEditor().should("be.visible") + cy.then(() => + expect(telemetryHeaders).to.deep.equal([`Bearer ${refreshed}`]), + ) + }) + + it("preserves other tabs' password credentials when signing in with SSO", () => { + const initial = idToken("initial", 3600) + signIn({ + idTokens: { authorization_code: initial }, + credentials: { + "rest.token": "expired-rest-token", + "basic.auth.header": "Basic expired", + }, + }) + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${initial}`) + cy.getEditor().should("be.visible") + cy.window().then(({ localStorage, sessionStorage }) => { + expect(localStorage.getItem("rest.token")).to.eq("expired-rest-token") + expect(localStorage.getItem("basic.auth.header")).to.eq("Basic expired") + expect(sessionStorage.getItem("auth.login.method")).to.eq("sso") + }) + }) + + it("refreshes an ID token that expires before the access token", () => { + // Given an ID token within the 30s refresh window, and an access token + // that is valid for an hour + const refreshed = idToken("refreshed", 3600) + const grants = signIn({ + idTokens: { + authorization_code: idToken("initial", 20), + refresh_token: refreshed, + }, + accessTokenLifetime: 3600, + }) + + // Then the console refreshes before its first query, and sends the new + // ID token + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${refreshed}`) + cy.wrap(grants).should("deep.equal", [ + "authorization_code", + "refresh_token", + ]) + }) + + it("does not refresh an ID token that outlives the access token", () => { + // Given an ID token that is valid for an hour, and an access token within + // the 30s refresh window + const initial = idToken("initial", 3600) + const grants = signIn({ + idTokens: { + authorization_code: initial, + refresh_token: idToken("refreshed", 3600), + }, + accessTokenLifetime: 20, + }) + + // Then the console keeps sending the initial ID token + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${initial}`) + cy.wrap(grants).should("deep.equal", ["authorization_code"]) + }) + + it("explains a sign-in without the ID token the server expects", () => { + // Given a provider that returns no ID token, as without the openid scope + signIn({ idTokens: {} }) + + // Then the console explains the problem instead of starting a session, + // and drops the used code from the URL + expectMissingIdTokenError("needed to sign you in.") + cy.get("@sessionStart.all").should("have.length", 0) + cy.location("search").should("eq", "") + }) + + it("explains a refresh without the ID token the server expects", () => { + // Given a provider that omits the ID token on refresh, which OIDC allows + const initial = idToken("initial", 20) + signIn({ idTokens: { authorization_code: initial } }) + + // Then the console ends the session and cancels the waiting SQL request. + expectMissingIdTokenError("needed to keep you signed in.") + cy.get("@authCheck.all").should("have.length", 0) + }) +}) + +describe("Auth - OIDC token without a lifetime", () => { + const tokenUrl = "https://oauth.example.com/token" + + beforeEach(() => { + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": false, + }, + }) + stubConsoleRequests() + }) + + for (const expires_in of ["3600", 0, -1, null]) { + it(`signs in with optional lifetime ${JSON.stringify(expires_in)} and null fields`, () => { + cy.intercept("POST", tokenUrl, { + body: { + access_token: "mock-access-token", + expires_in, + refresh_token: null, + id_token: null, + token_type: null, + error: null, + error_description: null, + }, + }) + visitCallback("?code=code-from-the-provider") + cy.getEditor().should("be.visible") + }) + } + + it("keeps recovery focus and announces failure before a successful retry", () => { + cy.intercept("POST", tokenUrl, { access_token: "mock-access-token" }) + let attempts = 0 + let completeRetry + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*`, query: { query: "SELECT 1" } }, + (req) => { + attempts++ + if (attempts === 1) { + req.reply({ statusCode: 502 }) + return + } + return new Promise((resolve) => { + completeRetry = () => { + req.reply( + attempts === 2 ? { statusCode: 502 } : { body: queryResult }, + ) + resolve() + } + }) + }, + ) + visitCallback("?code=code-from-the-provider") + cy.contains("QuestDB is not reachable [502]") + .should("be.visible") + .should(($error) => { + const win = $error[0].ownerDocument.defaultView + expect(win.getComputedStyle($error[0]).color).not.to.equal( + win.getComputedStyle(win.document.body).color, + ) + }) + cy.contains("h1", "Could not connect to QuestDB").should("be.focused") + cy.contains("button", "Retry connection").focus().click() + cy.contains("button", "Retry connection") + .should("be.focused") + .and("have.attr", "aria-disabled", "true") + .and("have.attr", "aria-busy", "true") + .and("not.be.disabled") + .click() + cy.get('[role="status"]').should("contain.text", "Reconnecting…") + cy.contains("QuestDB is not reachable [502]").should("be.visible") + cy.getEditor().should("not.exist") + cy.wrap(null).should(() => { + expect(attempts).to.equal(2) + expect(completeRetry).to.be.a("function") + }) + cy.then(() => completeRetry()) + cy.get('[role="status"]').should( + "contain.text", + "Connection failed. Retry connection.", + ) + cy.contains("button", "Retry connection") + .should("be.focused") + .and("have.attr", "aria-busy", "false") + .click() + cy.get('[role="status"]').should("contain.text", "Reconnecting…") + cy.wrap(null).should(() => expect(attempts).to.equal(3)) + cy.then(() => completeRetry()) + cy.getEditor().should("be.visible") + }) + + for (const expires_in of [0, "0"]) { + it(`renews a zero-lifetime token (${JSON.stringify(expires_in)}) before SQL`, () => { + cy.intercept("POST", tokenUrl, (req) => { + const body = new URLSearchParams(req.body) + const refreshing = body.get("grant_type") === "refresh_token" + if (refreshing) { + expect(body.get("refresh_token")).to.eq("initial-refresh-token") + req.alias = "zeroLifetimeRefresh" + } + req.reply({ + access_token: refreshing + ? "renewed-access-token" + : "expired-access-token", + refresh_token: refreshing + ? "rotated-refresh-token" + : "initial-refresh-token", + expires_in: refreshing ? 3600 : expires_in, + }) + }) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "SELECT 1" }, + }, + { statusCode: 200, body: queryResult }, + ).as("zeroLifetimeSQL") + visitCallback("?code=code-from-the-provider") + cy.wait("@zeroLifetimeRefresh") + cy.wait("@zeroLifetimeSQL") + .its("request.headers.authorization") + .should("eq", "Bearer renewed-access-token") + cy.getEditor().should("be.visible") + }) + } + + it("signs in, never refreshes the token, and signs out once the server rejects it", () => { + // Given a provider that sends no expires_in, although it sends a refresh + // token + const grants = [] + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + grants.push(new URLSearchParams(req.body).get("grant_type")) + req.reply({ + access_token: "mock-access-token", + refresh_token: "refresh-token", + token_type: "Bearer", + }) + }) + visitCallback("?code=code-from-the-provider") + cy.wait("@settings") + + // Then the console signs in, and uses the token without refreshing it + cy.getEditor().should("be.visible") + cy.wrap(grants).should("deep.equal", ["authorization_code"]) + + // When the server rejects the expired token + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejected") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + + // Then the user signs in again, without a refresh or a retried query + cy.getByDataHook("auth-login").should("be.visible") + cy.get("@rejected.all").should("have.length", 1) + cy.wrap(grants).should("deep.equal", ["authorization_code"]) + }) +}) diff --git a/e2e/tests/auth/unauthorized.spec.js b/e2e/tests/auth/unauthorized.spec.js new file mode 100644 index 000000000..946c203c7 --- /dev/null +++ b/e2e/tests/auth/unauthorized.spec.js @@ -0,0 +1,72 @@ +/// + +const { seedNotebookOnboarding } = require("../../utils") + +const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" +const baseUrl = `http://localhost:9999${contextPath}` + +for (const oidcEnabled of [false, true]) { + for (const { key, value, authorization } of [ + { + key: "rest.token", + value: "rest-token", + authorization: "Bearer rest-token", + }, + { + key: "basic.auth.header", + value: "Basic dXNlcjpwYXNz", + authorization: "Basic dXNlcjpwYXNz", + }, + ]) { + describe(`401 recovery: ${key}, OIDC ${oidcEnabled ? "on" : "off"}`, () => { + it("logs out the current tab even if another tab removed the saved credential", () => { + cy.intercept(`${baseUrl}/settings`, { + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": oidcEnabled, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": "https://oauth.example.com/token", + }, + }) + cy.intercept(`${baseUrl}/exec*`, { + columns: [], + dataset: [], + count: 0, + }) + cy.intercept(`${baseUrl}/warnings*`, []) + cy.intercept(`${baseUrl}/chk*`, { status: "Exists" }) + cy.visit(baseUrl, { + onBeforeLoad(win) { + win.localStorage.clear() + seedNotebookOnboarding(win) + win.localStorage.setItem(key, value) + }, + }) + cy.getEditor().should("be.visible") + // localStorage changes in another tab do not change this tab's headers. + cy.window().its("localStorage").invoke("removeItem", key) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + (req) => { + expect(req.headers.authorization).to.eq(authorization) + req.reply({ statusCode: 401, body: "Unauthorized" }) + }, + ).as("unauthorized") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@unauthorized") + cy.getByDataHook("auth-login").should("be.visible") + cy.getEditor().should("not.exist") + }) + }) + } +} diff --git a/e2e/tests/enterprise/oidc.spec.js b/e2e/tests/enterprise/oidc.spec.js index a1aa48aaf..97262b6e9 100644 --- a/e2e/tests/enterprise/oidc.spec.js +++ b/e2e/tests/enterprise/oidc.spec.js @@ -61,18 +61,27 @@ describe("OIDC", () => { cy.getEditor().should("not.exist") }) - it("should login via OIDC", () => { + it("refreshes an immediately expired zero-lifetime OIDC token before SQL", () => { interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) interceptTokenRequest({ access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", token_type: "Bearer", - expires_in: 300, + expires_in: 0, }) cy.getByDataHook("button-sso-login").click() cy.wait("@authorizationCode") - + cy.wait("@tokens").then(({ request }) => { + expect(new URLSearchParams(request.body).get("grant_type")).to.eq( + "authorization_code", + ) + }) + cy.wait("@tokens").then(({ request }) => { + expect(new URLSearchParams(request.body).get("grant_type")).to.eq( + "refresh_token", + ) + }) cy.getEditor().should("be.visible") cy.executeSQL("select current_user();") @@ -82,13 +91,242 @@ describe("OIDC", () => { cy.getByDataHook("auth-login").should("be.visible") }) + it("accepts an IdP's string expiry with null optional fields", () => { + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + interceptTokenRequest({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: null, + id_token: null, + token_type: "Bearer", + expires_in: "300", + }) + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + }) + + it("uses the SSO token instead of retained REST credentials", () => { + cy.window().then(({ localStorage }) => { + localStorage.setItem("rest.token", "expired-rest-token") + localStorage.setItem("basic.auth.header", "Basic expired") + }) + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + interceptTokenRequest({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 300, + }) + + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + cy.window().then(({ localStorage, sessionStorage }) => { + expect(localStorage.getItem("rest.token")).to.eq("expired-rest-token") + expect(localStorage.getItem("basic.auth.header")).to.eq("Basic expired") + expect(sessionStorage.getItem("auth.login.method")).to.eq("sso") + }) + }) + + for (const { name, reply } of [ + { name: "a network error", reply: { forceNetworkError: true } }, + { + name: "a non-JSON 502", + reply: { statusCode: 502, body: "Bad Gateway" }, + }, + ]) { + it(`continues loading the editor when token refresh fails with ${name}`, () => { + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + if ( + new URLSearchParams(req.body).get("grant_type") === "refresh_token" + ) { + req.alias = "refreshFailure" + req.reply(reply) + } else { + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 20, + }) + } + }).as("tokens") + + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.wait("@refreshFailure") + cy.getEditor().should("be.visible") + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + }) + } + + it("keeps the session after a refresh network failure and a later SQL 401, then recovers", () => { + // Freeze Date only: keep the bearer valid during refresh backoff while + // real timers continue driving requests and notification transitions. + cy.clock(Date.now(), ["Date"]) + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + if ( + new URLSearchParams(req.body).get("grant_type") === "refresh_token" + ) { + req.alias = "refreshFailure" + req.reply({ forceNetworkError: true }) + } else { + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 20, + }) + } + }) + + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.wait("@refreshFailure") + cy.getEditor().should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.client1") + .should("not.be.empty") + + cy.intercept( + { + method: "GET", + url: "**/exec?*", + query: { query: "select current_user()" }, + times: 1, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejectedBearerQuery") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@rejectedBearerQuery") + cy.contains("the request was not sent").should("be.visible") + cy.getEditor().should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.get("#sso-refresh-failed").should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.session.active") + .should("eq", "true") + + // The EE userinfo fixture recognizes this access token. Renew its + // lifetime and rotate the refresh token, then execute against real EE. + cy.intercept("POST", oidcTokenUrl, { + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "rotated-refresh-token", + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 3600, + }).as("refreshRecovery") + cy.intercept({ + method: "GET", + url: "**/exec?*", + query: { query: "select current_user()" }, + }).as("recoveredQuery") + cy.tick(5000) + cy.runLine() + cy.wait("@refreshRecovery") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", "Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom") // gitleaks:allow + cy.getGridRow(0).should("contain", "john doe") + cy.get("#sso-refresh-failed").should("not.exist") + cy.getByDataHook("auth-login").should("not.exist") + + // The next renewal must consume the refresh token from the recovery + // response, while keeping the bearer recognized by the EE fixture. + cy.intercept("POST", oidcTokenUrl, (req) => { + const body = new URLSearchParams(req.body) + expect(body.get("grant_type")).to.eq("refresh_token") + expect(body.get("refresh_token")).to.eq("rotated-refresh-token") + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "next-refresh-token", + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 3600, + }) + }).as("rotatedRefresh") + cy.tick(3600000) + cy.runLine() + cy.wait("@rotatedRefresh") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", "Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom") // gitleaks:allow + cy.getGridRow(0).should("contain", "john doe") + cy.getByDataHook("auth-login").should("not.exist") + }) + + it("shows the expired-session error when refresh is authoritatively rejected", () => { + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + if ( + new URLSearchParams(req.body).get("grant_type") === "refresh_token" + ) { + req.alias = "refreshRejected" + req.reply({ statusCode: 400, body: { error: "invalid_grant" } }) + } else { + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 300, + }) + } + }) + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.client1") + .should("not.be.empty") + + // The first SQL 401 triggers refresh while the token is locally valid. + // The invalid_grant, not that old bearer's rejection, ends the session. + cy.intercept( + { + method: "GET", + url: "**/exec?*", + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejectedBearerQuery") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@rejectedBearerQuery") + cy.wait("@refreshRejected") + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("Your SSO session has expired") + .closest("[data-hook='sign-in-error']") + .should("be.focused") + cy.contains( + "Could not renew your SSO session. Please sign in again.", + ).should("be.visible") + cy.getByDataHook("button-sso-continue").should("be.visible") + }) + it("should request a new token on page reload, even if there is no refresh token", () => { interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) interceptTokenRequest({ access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", token_type: "Bearer", - expires_in: 0, + expires_in: 300, }) cy.getByDataHook("button-sso-login").click() cy.wait("@authorizationCode") diff --git a/package.json b/package.json index 651435654..a8860a925 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@types/react-grid-layout": "^2.1.0", "@types/react-highlight-words": "^0.16.7", "@types/react-redux": "7.1.9", + "@types/react-test-renderer": "17.0.9", "@types/react-transition-group": "4.4.0", "@types/styled-components": "5.1.7", "@types/throttle-debounce": "2.1.0", @@ -144,6 +145,7 @@ "jiti": "^2.6.1", "lint-staged": "^16.2.6", "prettier": "^3.3.0", + "react-test-renderer": "17.0.2", "rollup-plugin-license": "^3.7.1", "sass": "^1.93.2", "stylelint": "^13.13.1", diff --git a/src/components/ConnectionErrorView.tsx b/src/components/ConnectionErrorView.tsx new file mode 100644 index 000000000..f056c2aaa --- /dev/null +++ b/src/components/ConnectionErrorView.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useRef } from "react" +import styled from "styled-components" +import { Box, Button, CenteredLayout, Text } from "." +import { CloseOutline, Refresh } from "./icons" + +const Heading = styled.h1` + margin: 0; + font-size: 2rem; + color: ${({ theme }) => theme.color.contentPrimary}; + outline: none; +` + +const ErrorIcon = styled(CloseOutline)` + color: ${({ theme }) => theme.color.statusDanger}; + flex-shrink: 0; +` + +export const ConnectionErrorView = ({ + title = "Could not connect to QuestDB", + error, + status, + pending = false, + onRetry, + retryLabel = "Retry connection", + onSignIn, + preloadedImages, +}: { + title?: string + error: React.ReactNode + status?: string + pending?: boolean + onRetry: () => void + retryLabel?: string + onSignIn?: () => void + preloadedImages?: Record +}) => { + const heading = useRef(null) + useEffect(() => heading.current?.focus(), []) + return ( + + + + {title} + +
+ + +
+ {status && ( +
+ {status} +
+ )} + + {/* Keep focus on Retry while it runs; the handler also guards re-entry. */} + + {onSignIn && } + +
+
+ ) +} diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index afe145b7b..9a78d9031 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -25,6 +25,7 @@ type StyledIconProps = { export type ToastOptions = RTToastOptions export const useNotificationCenter = RTNotificationCenter +export const REFRESH_FAILED_TOAST_ID = "sso-refresh-failed" export const ToastIcon = ({ type, diff --git a/src/components/TopBar/toolbar.tsx b/src/components/TopBar/toolbar.tsx index 5e57efbdc..bbba5612e 100644 --- a/src/components/TopBar/toolbar.tsx +++ b/src/components/TopBar/toolbar.tsx @@ -19,10 +19,10 @@ import { InstanceSettingsPopper } from "./InstanceSettingsPopper" import { pickReadableTextColor, Preferences } from "../../utils" import { PopperHover, Placement } from "../" import { useTheme } from "styled-components" -import { TelemetryTable } from "../../consts" -import { TelemetryConfigShape } from "../../store/Telemetry/types" +import { queryTelemetryConfig } from "../../utils/telemetryQueries" import { sendServerInfoTelemetry } from "../../utils/telemetry" -import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { isSessionEnded } from "../../modules/OAuth2/session" import { InstanceTypeIcon } from "./InstanceTypeIcon" const EnvIconWrapper = styled.div<{ $background?: string }>` @@ -376,13 +376,14 @@ export const Toolbar = () => { "SELECT current_user", { limit: "0,1", + background: true, }, ) if (response.type === QuestDB.Type.DQL && response.count === 1) { const currentUser = response.data[0].current_user setCurrentUser(currentUser) - const ssoAuthenticated = ssoAuthState.isSSOAuthenticated() + const ssoAuthenticated = authSessionState.isSSOAuthenticated() if (ssoAuthenticated && currentUser && settings["acl.oidc.client.id"]) { // it is an SSO user, we should update the SSO username setSSOUserNameWithClientID( @@ -436,24 +437,34 @@ export const Toolbar = () => { }, [result]) const handleSaveSettings = async (values: Preferences) => { + const session = authSessionState.capture() try { const result = await quest.savePreferences(values) + if (!session.isCurrent()) return if (result.success) { await handleToggle(false) + if (!session.isCurrent()) return toast.success("Instance information updated successfully.") - const response = await quest.query( - `${TelemetryTable.CONFIG} limit -1`, - ) - if (response.type === QuestDB.Type.DQL && response.count === 1) { - const serverInfo = response.data[0] - void sendServerInfoTelemetry(serverInfo) - } + // Telemetry is best-effort and cannot turn an accepted save into a + // failure notification (or renew an otherwise idle SSO session). + void queryTelemetryConfig(quest) + .then((response) => { + if ( + session.isCurrent() && + response.type === QuestDB.Type.DQL && + response.count === 1 + ) { + void sendServerInfoTelemetry(response.data[0]) + } + }) + .catch(() => {}) return } const { preferences: newPreferences } = await refreshSettingsAndPreferences() + if (!session.isCurrent()) return setPreviewValues(newPreferences) if (result.status === 409) { toast.error( @@ -465,9 +476,13 @@ export const Toolbar = () => { throw new Error(result.message) } catch (e) { - toast.error(`Failed to update instance information: ${e}`, { - autoClose: 5000, - }) + if (!session.isCurrent() || isSessionEnded(e)) return + toast.error( + `Failed to update instance information: ${e instanceof Error ? e.message : String(e)}`, + { + autoClose: 5000, + }, + ) } } diff --git a/src/hooks/catalogSource/requestDeadline.test.ts b/src/hooks/catalogSource/requestDeadline.test.ts new file mode 100644 index 000000000..9a4b48967 --- /dev/null +++ b/src/hooks/catalogSource/requestDeadline.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { createCatalogRequestDeadline } from "./requestDeadline" + +afterEach(() => { + vi.useRealTimers() +}) + +describe("catalog request deadline", () => { + it("allows a full SQL timeout after a hung refresh", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("table metadata", onTimeout) + deadline.onRefreshWait(10_000) + + await vi.advanceTimersByTimeAsync(10_000) + expect(onTimeout).not.toHaveBeenCalled() + deadline.onRequestStart() + await vi.advanceTimersByTimeAsync(9_999) + expect(onTimeout).not.toHaveBeenCalled() + + const rejection = expect(deadline.promise).rejects.toThrow( + "table metadata request timed out", + ) + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + }) + + it("allows a refresh after a rejected request before restarting the SQL timeout", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("query activity", onTimeout) + deadline.onRequestStart() + // The server rejects the token after 2s, then the refresh takes 8.5s. + await vi.advanceTimersByTimeAsync(2_000) + deadline.onRefreshWait(10_000) + await vi.advanceTimersByTimeAsync(8_500) + expect(onTimeout).not.toHaveBeenCalled() + + deadline.onRequestStart() + await vi.advanceTimersByTimeAsync(9_999) + expect(onTimeout).not.toHaveBeenCalled() + const rejection = expect(deadline.promise).rejects.toThrow( + "query activity request timed out", + ) + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + }) + + it("keeps the normal ten-second SQL timeout without refresh", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("columns", onTimeout) + deadline.onRequestStart() + await vi.advanceTimersByTimeAsync(9_999) + expect(onTimeout).not.toHaveBeenCalled() + + const rejection = expect(deadline.promise).rejects.toThrow( + "columns request timed out", + ) + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + }) + + it("clears the pending timeout after a successful request", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("columns", onTimeout) + deadline.onRequestStart() + deadline.clear() + + await vi.advanceTimersByTimeAsync(20_000) + expect(onTimeout).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/catalogSource/requestDeadline.ts b/src/hooks/catalogSource/requestDeadline.ts new file mode 100644 index 000000000..364197735 --- /dev/null +++ b/src/hooks/catalogSource/requestDeadline.ts @@ -0,0 +1,34 @@ +import { SOURCE_TIMEOUT_MS } from "./sourceState" + +export const createCatalogRequestDeadline = ( + sourceName: string, + onTimeout: () => void, +) => { + let timeoutId: ReturnType | null = null + let rejectTimeout!: (error: Error) => void + const promise = new Promise((_, reject) => { + rejectTimeout = reject + }) + const schedule = (delay: number) => { + if (timeoutId !== null) clearTimeout(timeoutId) + timeoutId = setTimeout(() => { + onTimeout() + rejectTimeout(new Error(`${sourceName} request timed out`)) + }, delay) + } + + // The refresh is bounded independently; SQL still gets its full timeout + // after onRequestStart, even if refreshing consumed the entire first 10s. + // A query can also wait for a refresh after a 401, between two requests. + const allowRefreshThenRequest = (refreshTimeoutMs: number) => + schedule(refreshTimeoutMs + SOURCE_TIMEOUT_MS) + schedule(SOURCE_TIMEOUT_MS) + return { + promise, + onRefreshWait: allowRefreshThenRequest, + onRequestStart: () => schedule(SOURCE_TIMEOUT_MS), + clear: () => { + if (timeoutId !== null) clearTimeout(timeoutId) + }, + } +} diff --git a/src/hooks/catalogSource/useCatalogSource.test.tsx b/src/hooks/catalogSource/useCatalogSource.test.tsx new file mode 100644 index 000000000..ed2ab9207 --- /dev/null +++ b/src/hooks/catalogSource/useCatalogSource.test.tsx @@ -0,0 +1,256 @@ +import "../../test/stubBrowserGlobals" +import React from "react" +import { act, create, ReactTestRenderer } from "react-test-renderer" +import { afterEach, describe, expect, it, vi } from "vitest" +import { QuestContext } from "../../providers" +import { useCatalogSource } from "./useCatalogSource" +import { MANUAL_RETRY_POLICY } from "./sourceState" +import { Client } from "../../utils/questdb/client" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import type { Options, QueryLifecycle } from "../../utils/questdb" +import type { AuthPayload } from "../../modules/OAuth2/types" + +vi.mock("../../providers", async () => { + const { createContext } = await import("react") + return { QuestContext: createContext({}) } +}) + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + act(() => renderer?.unmount()) + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + authSessionState.endSession() +}) + +const transformResponse = () => [] + +const renderSource = (quest: Client) => { + vi.spyOn(console, "error").mockImplementation(() => {}) + let status: string | undefined + let readyData: unknown + type Props = { sourceKey: string; enabled: boolean } + const Harness = ({ sourceKey, enabled }: Props) => { + const { state, lastReadyData } = useCatalogSource({ + sourceKey, + revalidateKey: 0, + sourceName: "Tables", + enabled, + query: sourceKey === "catalog" ? "tables()" : sourceKey, + pollIntervalMs: null, + retryPolicy: MANUAL_RETRY_POLICY, + transformResponse, + }) + status = state.status + readyData = lastReadyData + return null + } + const value = { + quest, + questExecution: {} as React.ContextType< + typeof QuestContext + >["questExecution"], + buildVersion: { type: "oss" as const, version: "" }, + commitHash: "", + } + const tree = (props: Props) => ( + + + + ) + act(() => { + renderer = create(tree({ sourceKey: "catalog", enabled: true })) + }) + return { + status: () => status, + data: () => readyData, + update: (props: Props) => act(() => renderer?.update(tree(props))), + } +} + +const mount = (startImmediately: boolean) => { + vi.useFakeTimers() + const abort = vi.fn() + const queryRaw = vi.fn( + (_query: string, _options: Options, lifecycle: QueryLifecycle) => { + if (startImmediately) lifecycle.onRequestStart?.() + else lifecycle.onRefreshWait?.(10_000) + return { queryId: 1, promise: new Promise(() => {}) } + }, + ) + const { status } = renderSource({ queryRaw, abort } as unknown as Client) + return { abort, queryRaw, status } +} + +const advance = async (ms: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +describe("useCatalogSource deadline integration", () => { + it("wires onRequestStart so SQL gets 10 seconds, not the combined 20", async () => { + const hook = mount(true) + expect(hook.queryRaw.mock.calls[0][2].onRequestStart).toBeTypeOf("function") + await advance(9_999) + expect(hook.status()).toBe("loading") + expect(hook.abort).not.toHaveBeenCalled() + await advance(1) + expect(hook.status()).toBe("unavailable") + expect(hook.abort).toHaveBeenCalledWith(1) + }) + + it("gives SQL its full budget after waiting for refresh", async () => { + const hook = mount(false) + await advance(9_000) + act(() => hook.queryRaw.mock.calls[0][2].onRequestStart?.()) + await advance(9_999) + expect(hook.status()).toBe("loading") + await advance(1) + expect(hook.status()).toBe("unavailable") + expect(hook.abort).toHaveBeenCalledWith(1) + }) + + it("still times out if no request-start callback ever arrives", async () => { + const hook = mount(false) + await advance(20_000) + expect(hook.status()).toBe("unavailable") + expect(hook.abort).toHaveBeenCalledWith(1) + }) + + it("waits for the refresh after a 401, then shows the retried result", async () => { + vi.useFakeTimers() + const later = (ms: number, value: T) => + new Promise((resolve) => setTimeout(() => resolve(value), ms)) + // The browser thinks the token is valid, but the server has expired it. + const issue = (access_token: string) => ({ + access_token, + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + 300_000).toISOString(), + }) + authSessionState.setAuthPayload(issue("stale")) + const fetchMock = vi + .fn() + .mockImplementationOnce(() => + later(2_000, { + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response), + ) + .mockResolvedValue({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ columns: [], count: 0, dataset: [] }), + ), + } as Response) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + const refresh = vi.fn(() => + later(8_500, issue("fresh")).then((payload) => { + authSessionState.setAuthPayload(payload) + return payload + }), + ) + client.refreshTokenMethod = refresh + const { status } = renderSource(client) + + // The refresh ends 10.5s after the first request started: past that + // request's 10s timeout, but within the refresh's own 10s bound. + await advance(10_000) + expect(status()).toBe("loading") + await advance(500) + expect(refresh).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer fresh" } }), + ) + expect(status()).toBe("ready") + }) + + it.each(["retarget", "disable", "unmount"])( + "cleans an abandoned source during shared refresh on %s", + async (change) => { + vi.useFakeTimers() + const client = new Client() + const abort = vi.spyOn(client, "abort") + authSessionState.setAuthPayload({ + access_token: "old", + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + 20_000).toISOString(), + }) + let finishRefresh!: () => void + const refresh = vi.fn( + () => + new Promise((resolve) => { + finishRefresh = () => { + const payload = { + access_token: "fresh", + token_type: "Bearer", + refresh_token: "rotated", + expires_at: new Date(Date.now() + 300_000).toISOString(), + } + authSessionState.setAuthPayload(payload) + resolve(payload) + } + }), + ) + client.refreshTokenMethod = refresh + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ columns: [], count: 0, dataset: [] }), + ), + ), + ) + vi.stubGlobal("fetch", fetchMock) + const source = renderSource(client) + await advance(1_000) + // Another source must still be able to use the same refresh. + const current = client.queryRaw("current-source") + if (change === "unmount") { + act(() => renderer?.unmount()) + } else { + source.update({ + sourceKey: "new-source", + enabled: change === "retarget", + }) + } + await advance(0) + expect(abort).toHaveBeenCalledWith(1) + expect(fetchMock).not.toHaveBeenCalled() + expect(source.data()).toBeNull() + finishRefresh() + await advance(0) + await current + + expect(refresh).toHaveBeenCalledTimes(1) + const queries = fetchMock.mock.calls.map(([url]) => + new URL(url as string, "http://localhost").searchParams.get("query"), + ) + expect(queries).not.toContain("tables()") + expect(queries).toEqual( + change === "retarget" + ? ["current-source", "new-source"] + : ["current-source"], + ) + if (change === "retarget") { + expect(source.status()).toBe("ready") + expect(source.data()).toEqual([]) + } + expect(vi.getTimerCount()).toBe(0) + await advance(20_000) + expect(abort).toHaveBeenCalledTimes(1) + }, + ) +}) diff --git a/src/hooks/catalogSource/useCatalogSource.ts b/src/hooks/catalogSource/useCatalogSource.ts index 6bc4b1628..d44824952 100644 --- a/src/hooks/catalogSource/useCatalogSource.ts +++ b/src/hooks/catalogSource/useCatalogSource.ts @@ -4,11 +4,12 @@ import * as QuestDB from "../../utils/questdb" import { createSourceMachineState, nextSourceState, - SOURCE_TIMEOUT_MS, type SourceMachineState, type SourceRetryPolicy, } from "./sourceState" import type { SourceFetchOutcome, SourceState } from "./types" +import { createCatalogRequestDeadline } from "./requestDeadline" +import { isCancelledByUser } from "../../utils/questdb/cancellation" type Params = { sourceKey: string @@ -28,12 +29,6 @@ type CatalogSource = { poll: () => Promise } -const isCancelledRequest = (error: unknown): boolean => - typeof error === "object" && - error !== null && - "error" in error && - error.error === "Cancelled by user" - export const useCatalogSource = ({ sourceKey, revalidateKey, @@ -62,25 +57,27 @@ export const useCatalogSource = ({ const runRequest = useCallback(async (): Promise => { const requestKey = sourceKey let queryId: QuestDB.QueryId | null = null - let timeoutId: number | null = null let timedOut = false + const deadline = createCatalogRequestDeadline(sourceName, () => { + timedOut = true + if (queryId !== null && activeQueryIdRef.current === queryId) { + quest.abort(queryId) + } + }) try { - const request = quest.queryRaw(query, { cancellable: true }) + const request = quest.queryRaw( + query, + { cancellable: true, background: true }, + { + onRefreshWait: deadline.onRefreshWait, + onRequestStart: deadline.onRequestStart, + }, + ) queryId = request.queryId activeQueryIdRef.current = request.queryId - const timeout = new Promise((_, reject) => { - timeoutId = window.setTimeout(() => { - timedOut = true - if (activeQueryIdRef.current === request.queryId) { - quest.abort(request.queryId) - } - reject(new Error(`${sourceName} request timed out`)) - }, SOURCE_TIMEOUT_MS) - }) - - const response = await Promise.race([request.promise, timeout]) + const response = await Promise.race([request.promise, deadline.promise]) if ( currentKeyRef.current !== requestKey || activeQueryIdRef.current !== queryId @@ -106,7 +103,7 @@ export const useCatalogSource = ({ ) { return "skipped" } - if (isCancelledRequest(error) && !timedOut) return "skipped" + if (isCancelledByUser(error) && !timedOut) return "skipped" setMachine((previous) => nextSourceState( @@ -120,9 +117,7 @@ export const useCatalogSource = ({ console.error(`Failed to fetch ${sourceName}:`, error) return "failure" } finally { - if (timeoutId !== null) { - window.clearTimeout(timeoutId) - } + deadline.clear() if (activeQueryIdRef.current === queryId) { activeQueryIdRef.current = null } diff --git a/src/hooks/useQueryExecution.ts b/src/hooks/useQueryExecution.ts index 82bac71ef..daf46be44 100644 --- a/src/hooks/useQueryExecution.ts +++ b/src/hooks/useQueryExecution.ts @@ -21,8 +21,9 @@ export const useQueryExecution = (globals?: NotebookVariable[]) => { sql: string, signal?: AbortSignal, limit: number = RESULT_DISPLAY_LIMIT, + options?: { background?: boolean }, ): Promise => - executeSingleRaw(quest, sql, globals, signal, limit), + executeSingleRaw(quest, sql, globals, signal, limit, options), [quest, globals], ) diff --git a/src/index.tsx b/src/index.tsx index c8a85ff0d..c2e854ca4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -90,8 +90,8 @@ ReactDOM.render( + {ReactDOM.createPortal(, document.body)} - {ReactDOM.createPortal(, document.body)} diff --git a/src/js/console/quick-vis.ts b/src/js/console/quick-vis.ts index d9a84b094..7b70d580d 100644 --- a/src/js/console/quick-vis.ts +++ b/src/js/console/quick-vis.ts @@ -309,6 +309,7 @@ export function quickVis( cols: urlColumns, src: "vis", cancellable: true, + background: true, }) activeQueryId = queryId try { diff --git a/src/modules/EventBus/types.ts b/src/modules/EventBus/types.ts index dfd6d25a5..6e36b6014 100644 --- a/src/modules/EventBus/types.ts +++ b/src/modules/EventBus/types.ts @@ -15,6 +15,7 @@ export enum EventType { MSG_CONNECTION_ERROR = "query.connection.error", MSG_CONNECTION_UNAUTHORIZED = "query.connection.unauthorized", MSG_CONNECTION_FORBIDDEN = "query.connection.forbidden", + MSG_AUTH_REFRESH_FAILED = "auth.refresh.failed", REACT_READY = "react.ready", TAB_FOCUS = "tab.focus", TAB_BLUR = "tab.blur", diff --git a/src/modules/OAuth2/authSessionState.ts b/src/modules/OAuth2/authSessionState.ts new file mode 100644 index 000000000..36e06701b --- /dev/null +++ b/src/modules/OAuth2/authSessionState.ts @@ -0,0 +1,86 @@ +import { AuthPayload, LogoutOptions } from "./types" + +export type AuthSession = { isCurrent: () => boolean } + +// Tab-local authentication for ALL login methods. The SSO payload is only +// present for OIDC; stored REST/basic credentials do not make another tab +// authenticated. Keep these predicates distinct when handling a 401. +class AuthSessionState { + private authPayload: AuthPayload | null = null + private refreshFailed = false + private authenticated = false + private sessionId = 0 + private beforeEndListeners = new Set<() => void>() + private endListeners = new Set<(options?: LogoutOptions) => void>() + + capture(): AuthSession { + const sessionId = this.sessionId + return { isCurrent: () => this.sessionId === sessionId } + } + + // Only already-accepted data may be flushed here, never new network work. + beforeEnd(listener: () => void) { + this.beforeEndListeners.add(listener) + return () => { + this.beforeEndListeners.delete(listener) + } + } + + onEnd(listener: (options?: LogoutOptions) => void) { + this.endListeners.add(listener) + return () => { + this.endListeners.delete(listener) + } + } + + getSessionId() { + return this.sessionId + } + + // This state belongs to the tab, unlike credentials in localStorage. + markNonSSOAuthenticated() { + if (!this.authenticated) this.sessionId++ + this.authenticated = true + } + + // Any successful login in this tab (SSO, password or HTTP basic). + isAuthenticated(): boolean { + return this.authenticated + } + + setAuthPayload(authPayload: AuthPayload) { + if (!this.authPayload) this.sessionId++ + this.authPayload = authPayload + this.authenticated = true + this.refreshFailed = false + } + + markRefreshFailed() { + this.refreshFailed = true + } + + hasRefreshFailed(): boolean { + return this.refreshFailed + } + + getAuthPayload(): AuthPayload | null { + return this.authPayload + } + + // An installed OIDC session, eligible for token refresh. + isSSOAuthenticated(): boolean { + return !!this.authPayload + } + + // Ends the tab's session for every login method, not just OIDC. + endSession(options?: LogoutOptions) { + this.beforeEndListeners.forEach((listener) => listener()) + this.sessionId++ + this.authPayload = null + this.authenticated = false + this.refreshFailed = false + this.endListeners.forEach((listener) => listener(options)) + } +} + +export const authSessionState = new AuthSessionState() diff --git a/src/modules/OAuth2/handleUnauthorized.test.ts b/src/modules/OAuth2/handleUnauthorized.test.ts new file mode 100644 index 000000000..23efc74eb --- /dev/null +++ b/src/modules/OAuth2/handleUnauthorized.test.ts @@ -0,0 +1,80 @@ +import "../../test/stubBrowserGlobals" +import { afterEach, describe, expect, it, vi } from "vitest" +import { StoreKey } from "../../utils/localStorage/types" +import { authSessionState } from "./authSessionState" +import { handleSSOUnauthorized } from "./handleUnauthorized" +import type { AuthPayload } from "./types" + +afterEach(() => { + localStorage.clear() + authSessionState.endSession() +}) + +const login = () => { + authSessionState.setAuthPayload({ access_token: "token" } as AuthPayload) + localStorage.setItem(StoreKey.SSO_SESSION_ACTIVE, "true") +} + +describe("SSO unauthorized responses", () => { + it("ignores a burst of 401s after logout and preserves the refresh error", () => { + login() + authSessionState.markRefreshFailed() + const logout = vi.fn(() => authSessionState.endSession()) + + for (let i = 0; i < 6; i++) handleSSOUnauthorized(logout) + + expect(logout).toHaveBeenCalledTimes(1) + expect(logout).toHaveBeenCalledWith({ + errorTitle: "Your SSO session has expired", + errorMessage: "Could not renew your SSO session. Please sign in again.", + }) + expect(localStorage.getItem(StoreKey.OAUTH_REDIRECT_COUNT)).toBe("1") + expect(localStorage.getItem(StoreKey.SSO_SESSION_ACTIVE)).toBe("true") + }) + + it.each([StoreKey.REST_TOKEN, StoreKey.BASIC_AUTH_HEADER])( + "handles the current tab's 401 even after another tab removes %s", + (key) => { + localStorage.setItem(key, "credential") + authSessionState.markNonSSOAuthenticated() + localStorage.removeItem(key) + const logout = vi.fn(() => authSessionState.endSession()) + handleSSOUnauthorized(logout) + handleSSOUnauthorized(logout) + expect(logout).toHaveBeenCalledTimes(1) + expect(logout).toHaveBeenCalledWith({}) + }, + ) + + it("does not treat another tab's stored credentials as a login in this tab", () => { + localStorage.setItem(StoreKey.REST_TOKEN, "another-tab") + const logout = vi.fn() + handleSSOUnauthorized(logout) + expect(logout).not.toHaveBeenCalled() + }) + + it("handles a new password login after the previous session logged out", () => { + const logout = vi.fn(() => authSessionState.endSession()) + for (let i = 0; i < 2; i++) { + authSessionState.markNonSSOAuthenticated() + handleSSOUnauthorized(logout) + } + expect(logout).toHaveBeenCalledTimes(2) + }) + + it("still breaks a loop of six distinct failed logins", () => { + const logout = vi.fn(() => authSessionState.endSession()) + + for (let i = 0; i < 6; i++) { + login() + handleSSOUnauthorized(logout) + } + + expect(logout).toHaveBeenCalledTimes(6) + expect(logout).toHaveBeenLastCalledWith({ + promptForLogin: true, + clearSSOSession: true, + }) + expect(localStorage.getItem(StoreKey.OAUTH_REDIRECT_COUNT)).toBeNull() + }) +}) diff --git a/src/modules/OAuth2/handleUnauthorized.ts b/src/modules/OAuth2/handleUnauthorized.ts new file mode 100644 index 000000000..673d69a34 --- /dev/null +++ b/src/modules/OAuth2/handleUnauthorized.ts @@ -0,0 +1,32 @@ +import { getValue, removeValue, setValue } from "../../utils/localStorage" +import { StoreKey } from "../../utils/localStorage/types" +import { authSessionState } from "./authSessionState" + +import type { LogoutOptions } from "./types" +import { sessionRenewalError } from "./utils" + +type Logout = (options?: LogoutOptions) => void + +// Handles both OIDC and password sessions on an OIDC-enabled server. Only +// OIDC refresh failures receive the expired-SSO message. +export const handleSSOUnauthorized = (logout: Logout) => { + // A failed refresh can release many queries with the same expired token. + // Once the first 401 logs out, their remaining responses are not new + // redirects and must not clear the saved SSO session or login error. + if (!authSessionState.isAuthenticated()) { + return + } + + const logoutOptions = authSessionState.hasRefreshFailed() + ? sessionRenewalError() + : {} + const count = parseInt(getValue(StoreKey.OAUTH_REDIRECT_COUNT), 10) + if (!isNaN(count) && count >= 5) { + // Distinct failed sign-ins still trip the redirect-loop guard. + removeValue(StoreKey.OAUTH_REDIRECT_COUNT) + logout({ ...logoutOptions, promptForLogin: true, clearSSOSession: true }) + } else { + setValue(StoreKey.OAUTH_REDIRECT_COUNT, JSON.stringify((count || 0) + 1)) + logout(logoutOptions) + } +} diff --git a/src/modules/OAuth2/session.ts b/src/modules/OAuth2/session.ts new file mode 100644 index 000000000..3886915c6 --- /dev/null +++ b/src/modules/OAuth2/session.ts @@ -0,0 +1,53 @@ +import type { AuthSession } from "./authSessionState" +import type { SessionFailure } from "../../utils/questdb/types" + +export type { SessionFailure } from "../../utils/questdb/types" + +export const endedFor = (session: AuthSession, error: unknown): boolean => + !session.isCurrent() || isSessionEnded(error) + +export const sessionFailureOf = (error: unknown): SessionFailure => ({ + ...(isSessionEnded(error) ? { sessionEnded: true } : {}), + ...(typeof error === "object" && + error !== null && + "requestNotSent" in error && + error.requestNotSent === true + ? { requestNotSent: true } + : {}), +}) + +export const SESSION_ENDED_MESSAGE = + "Your session has ended. Please sign in again." + +export class SessionEndedError extends Error { + constructor() { + super(SESSION_ENDED_MESSAGE) + this.name = "SessionEndedError" + } +} + +export const isSessionEnded = (error: unknown): boolean => + error instanceof SessionEndedError || + (typeof error === "object" && + error !== null && + "sessionEnded" in error && + error.sessionEnded === true) + +export const isAuthRefreshUnavailable = (error: unknown): boolean => + error instanceof AuthRefreshUnavailableError || + (typeof error === "object" && + error !== null && + "authRefreshUnavailable" in error && + error.authRefreshUnavailable === true) + +export class AuthRefreshUnavailableError extends Error { + readonly requestNotSent = true + readonly authRefreshUnavailable = true + + constructor() { + super( + "Could not renew your SSO session; the request was not sent. Please retry or sign in again.", + ) + this.name = "AuthRefreshUnavailableError" + } +} diff --git a/src/modules/OAuth2/ssoAuthState.ts b/src/modules/OAuth2/ssoAuthState.ts deleted file mode 100644 index 7ad416b6d..000000000 --- a/src/modules/OAuth2/ssoAuthState.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AuthPayload } from "./types" - -class SsoAuthState { - private authPayload: AuthPayload | null = null - - setAuthPayload(authPayload: AuthPayload) { - this.authPayload = authPayload - } - - getAuthPayload(): AuthPayload | null { - return this.authPayload - } - - isSSOAuthenticated(): boolean { - return !!this.authPayload - } - - clearAuthPayload() { - this.authPayload = null - } -} - -export const ssoAuthState = new SsoAuthState() diff --git a/src/modules/OAuth2/types.ts b/src/modules/OAuth2/types.ts index 5453ec898..2f0c3f0b7 100644 --- a/src/modules/OAuth2/types.ts +++ b/src/modules/OAuth2/types.ts @@ -1,9 +1,34 @@ export type AuthPayload = { access_token: string - id_token: string - refresh_token: string + id_token?: string + refresh_token?: string token_type: string - expires_in: number - expires_at: string + // The access token's lifetime. OAuth only recommends it: an ID token bearer + // expires at its exp claim instead. + expires_in?: number + // Absent when the bearer's expiry is unknown. Such a token is never + // refreshed: it is used until the server rejects it, then the user signs in. + expires_at?: string groups_encoded_in_token?: boolean } + +// Normalised fields from the token endpoint, not an installed client session. +// Unknown wire fields (including provider-supplied expires_at) are discarded. +export type TokenResponse = { + access_token?: string + id_token?: string + refresh_token?: string + token_type?: string + expires_in?: number + error?: string + error_description?: string +} + +export type SignInError = { errorTitle: string; errorMessage: string } + +export type LogoutOptions = Partial & { + promptForLogin?: boolean + isDisconnection?: boolean + reload?: boolean + clearSSOSession?: boolean +} diff --git a/src/modules/OAuth2/utils.test.ts b/src/modules/OAuth2/utils.test.ts new file mode 100644 index 000000000..e0d1b8a43 --- /dev/null +++ b/src/modules/OAuth2/utils.test.ts @@ -0,0 +1,596 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Base64 } from "js-base64" +import type { Settings } from "../../providers/SettingsProvider/types" +import { + TOKEN_REQUEST_TIMEOUT_MS, + exchangeAuthorizationCode, + getAuthToken, + getMissingIdTokenError, + getBearerAuthorization, + getTokenExpirationDate, + readAuthTokenResponse, + buildInstalledPayload, + getStoredCredentialAuthorization, +} from "./utils" +import "../../test/stubBrowserGlobals" +import { StoreKey } from "../../utils/localStorage/types" +import type { AuthPayload } from "./types" + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + vi.restoreAllMocks() + localStorage.clear() +}) + +describe("installed payload normalization", () => { + const previous = { + access_token: "old", + refresh_token: "r1", + token_type: "Bearer", + } + it("retains only a refresh grant's omitted refresh token", () => { + expect( + buildInstalledPayload( + { access_token: "new" }, + false, + "refresh_token", + previous, + ), + ).toMatchObject({ + payload: { + access_token: "new", + refresh_token: "r1", + expires_at: undefined, + }, + }) + expect( + buildInstalledPayload( + { access_token: "new" }, + false, + "authorization_code", + previous, + ), + ).toMatchObject({ payload: { refresh_token: undefined } }) + }) + it("installs a rotated refresh token", () => { + expect( + buildInstalledPayload( + { access_token: "new", refresh_token: "r2" }, + false, + "refresh_token", + previous, + ), + ).toMatchObject({ payload: { refresh_token: "r2" } }) + }) + it("uses consistent actionable wording for a rejected refresh grant", () => { + expect( + buildInstalledPayload( + { error: "invalid_grant", error_description: "Revoked." }, + false, + "refresh_token", + previous, + ), + ).toEqual({ + error: { + errorTitle: "Your SSO session has expired", + errorMessage: "Revoked. Please sign in again.", + }, + }) + }) +}) + +describe("token expiration", () => { + const now = Date.UTC(2026, 0, 1) + const nowSeconds = now / 1000 + + // Unsigned, as only the claims are read: the server verifies the signature. + const jwt = (claims: Record) => + [{ alg: "RS256", typ: "JWT" }, claims] + .map((part) => Base64.encodeURI(JSON.stringify(part))) + .concat("signature") + .join(".") + + const expiration = (tokenResponse: { + groups_encoded_in_token: boolean + expires_in?: number + id_token?: string + }) => getTokenExpirationDate(tokenResponse as AuthPayload)?.getTime() + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + it("uses expires_in when the access token is the bearer", () => { + expect( + expiration({ + groups_encoded_in_token: false, + expires_in: 300, + id_token: jwt({ iat: nowSeconds, exp: nowSeconds + 3600 }), + }), + ).toBe(now + 300_000) + }) + + it.each([ + { relation: "expires before", expiresIn: 3600, idTokenLifetime: 300 }, + { relation: "outlives", expiresIn: 300, idTokenLifetime: 3600 }, + ])( + "uses the exp claim when the ID token bearer $relation the access token", + ({ expiresIn, idTokenLifetime }) => { + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: expiresIn, + id_token: jwt({ + iat: nowSeconds, + exp: nowSeconds + idTokenLifetime, + }), + }), + ).toBe(now + idTokenLifetime * 1000) + }, + ) + + it("does not rebase exp onto a backdated iat", () => { + // Entra ID backdates iat by 5 minutes; the token still expires at exp. + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: 7200, + id_token: jwt({ iat: nowSeconds - 300, exp: nowSeconds + 3600 }), + }), + ).toBe(now + 3_600_000) + }) + + it("decodes a base64url payload without padding", () => { + const idToken = jwt({ + iat: nowSeconds, + exp: nowSeconds + 300, + name: "Zoë ???>>>", + }) + const payload = idToken.split(".")[1] + // Guard the fixture: it must differ from standard, padded base64. + expect(payload).toMatch(/[-_]/) + expect(payload.length % 4).not.toBe(0) + expect(payload).not.toContain("=") + + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: 3600, + id_token: idToken, + }), + ).toBe(now + 300_000) + }) + + it.each([ + { problem: "is missing", idToken: undefined }, + { problem: "is not a JWT", idToken: "opaque-token" }, + { problem: "has an empty payload", idToken: "header..signature" }, + { problem: "has an undecodable payload", idToken: "header.%%%.signature" }, + { + problem: "has a payload that is not JSON", + idToken: `header.${Base64.encodeURI("not json")}.signature`, + }, + { + problem: "has a null payload", + idToken: `header.${Base64.encodeURI("null")}.signature`, + }, + { problem: "has no exp claim", idToken: jwt({ iat: nowSeconds }) }, + { + problem: "has a non-numeric exp claim", + idToken: jwt({ iat: nowSeconds, exp: String(nowSeconds + 3600) }), + }, + ])("falls back to expires_in when the ID token $problem", ({ idToken }) => { + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: 300, + id_token: idToken, + }), + ).toBe(now + 300_000) + }) + + it("does not need expires_in for an ID token bearer with an exp claim", () => { + // OAuth recommends, but does not require, the access token's lifetime. + expect( + expiration({ + groups_encoded_in_token: true, + id_token: jwt({ iat: nowSeconds, exp: nowSeconds + 600 }), + }), + ).toBe(now + 600_000) + }) + + it.each([false, true])( + "expires immediately for zero lifetime (ID token bearer: %s)", + (groupsEncodedInToken) => { + expect( + expiration({ + groups_encoded_in_token: groupsEncodedInToken, + expires_in: 0, + id_token: "opaque-token", + }), + ).toBe(now) + }, + ) + + it.each([ + { + bearer: "an access token without expires_in", + tokenResponse: { + groups_encoded_in_token: false, + id_token: jwt({ iat: nowSeconds, exp: nowSeconds + 600 }), + }, + }, + { + bearer: "an ID token without an exp claim or expires_in", + tokenResponse: { + groups_encoded_in_token: true, + id_token: jwt({ iat: nowSeconds }), + }, + }, + { + bearer: "an access token with a negative expires_in", + tokenResponse: { groups_encoded_in_token: false, expires_in: -1 }, + }, + ])("has no expiration for $bearer", ({ tokenResponse }) => { + expect(expiration(tokenResponse)).toBeUndefined() + }) +}) + +describe("missing ID token", () => { + it.each([ + { + grantType: "authorization_code", + detail: "needed to sign you in.", + }, + { + grantType: "refresh_token", + detail: "needed to keep you signed in.", + }, + ] as const)( + "explains a missing ID token bearer after the $grantType grant", + ({ grantType, detail }) => { + const error = getMissingIdTokenError( + { groups_encoded_in_token: true } as AuthPayload, + grantType, + ) + expect(error?.errorTitle).toBe("Missing ID token") + expect(error?.errorMessage).toContain( + "ask your administrator to check the SSO configuration", + ) + expect(error?.errorMessage).toContain(detail) + expect(error?.errorMessage).not.toContain("acl.oidc") + }, + ) + + it.each([ + { + bearer: "the access token", + tokenResponse: { groups_encoded_in_token: false }, + }, + { + bearer: "a present ID token", + tokenResponse: { groups_encoded_in_token: true, id_token: "id-token" }, + }, + ])("reports nothing when $bearer is the bearer", ({ tokenResponse }) => { + expect( + getMissingIdTokenError(tokenResponse as AuthPayload, "refresh_token"), + ).toBeUndefined() + }) +}) + +describe("normalised token responses", () => { + it.each(["3599", 3599, "0", 0])("accepts lifetime %j", async (expires_in) => { + const result = await readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "access", + expires_in, + }), + ), + ) + expect(result).toEqual({ + access_token: "access", + expires_in: Number(expires_in), + }) + }) + + it.each([-1, 1e20, null, "unknown", "", {}, "Infinity"])( + "treats unusable optional lifetime %j as unknown", + async (expires_in) => { + await expect( + readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "access", + expires_in, + }), + ), + ), + ).resolves.toEqual({ access_token: "access" }) + }, + ) + + it("drops null optional fields, OAuth errors and client-derived fields", async () => { + await expect( + readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "access", + id_token: null, + refresh_token: null, + token_type: null, + error: null, + error_description: null, + expires_at: "2000-01-01", + groups_encoded_in_token: true, + extra: "ignored", + }), + ), + ), + ).resolves.toEqual({ access_token: "access" }) + }) + + it("does not expose a malformed response body through parser diagnostics", async () => { + await expect( + readAuthTokenResponse( + new Response('{"access_token":"secret"} malformed'), + ), + ).rejects.toThrow("Invalid token response: expected JSON") + }) + + it("identifies the malformed field without including credentials", async () => { + await expect( + readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "secret", + refresh_token: 123, + }), + ), + ), + ).rejects.toThrow("Invalid token response: refresh_token must be a string") + }) +}) + +describe("credential precedence", () => { + it("prefers the tab's SSO bearer over stored REST/basic credentials", () => { + localStorage.setItem(StoreKey.REST_TOKEN, "rest") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic password") + expect(getBearerAuthorization({ access_token: "sso" })).toBe("Bearer sso") + expect(getStoredCredentialAuthorization()).toBe("Bearer rest") + localStorage.removeItem(StoreKey.REST_TOKEN) + expect(getStoredCredentialAuthorization()).toBe("Basic password") + localStorage.clear() + expect(getStoredCredentialAuthorization()).toBeUndefined() + }) +}) + +describe("authorization code exchange", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + const settings = { "acl.oidc.token.endpoint": "/token" } as Settings + const payload = { grant_type: "authorization_code", code: "code" } + + it("resolves with the token response, even an OAuth error", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 400, + json: () => Promise.resolve({ error: "invalid_grant" }), + }), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { + tokenResponse: { error: "invalid_grant" }, + }, + ) + }) + + it("explains a provider that cannot be reached", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { + signInError: { + errorTitle: "Something went wrong.", + errorMessage: + "Could not reach the identity provider to complete the sign-in. Please try again.", + }, + }, + ) + }) + + it("explains a response that is not JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 502, + json: () => Promise.reject(new SyntaxError("Unexpected token '<'")), + }), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { + signInError: { + errorTitle: "Something went wrong.", + errorMessage: + "The identity provider returned an unexpected response (HTTP 502). Please try again.", + }, + }, + ) + }) + + const timedOut = { + signInError: { + errorTitle: "Something went wrong.", + errorMessage: + "The identity provider did not respond within 10 seconds. Please try again.", + }, + } + + it("aborts a request that gets no response within the timeout", async () => { + vi.useFakeTimers() + // Like a browser fetch, the request only ends when it is aborted + const fetchMock = vi.fn( + (_url: string, init: RequestInit) => + new Promise((_, reject) => { + init.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + vi.stubGlobal("fetch", fetchMock) + let settled = false + const exchange = exchangeAuthorizationCode(settings, payload).finally( + () => { + settled = true + }, + ) + + await vi.advanceTimersByTimeAsync(TOKEN_REQUEST_TIMEOUT_MS - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + + await expect(exchange).resolves.toEqual(timedOut) + expect(fetchMock.mock.calls[0][1].signal?.aborted).toBe(true) + }) + + it("gives up on a response body that ignores the abort", async () => { + vi.useFakeTimers() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + json: () => new Promise(() => {}), + }), + ) + const exchange = exchangeAuthorizationCode(settings, payload) + + await vi.advanceTimersByTimeAsync(TOKEN_REQUEST_TIMEOUT_MS) + + await expect(exchange).resolves.toEqual(timedOut) + }) + + it.each([ + null, + [], + "token", + 123, + { access_token: 123 }, + { id_token: 123 }, + { refresh_token: {} }, + ])("explains an invalid JSON token response: %j", async (body) => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue({ status: 200, json: () => Promise.resolve(body) }), + ) + await expect( + exchangeAuthorizationCode(settings, payload), + ).resolves.toMatchObject({ + signInError: { + errorMessage: + "The identity provider returned an invalid token response. Please ask your administrator to check the SSO configuration.", + }, + }) + }) + + it("clears the timeout once the response arrives", async () => { + vi.useFakeTimers() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ access_token: "token" }), + }), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { tokenResponse: { access_token: "token" } }, + ) + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe("bearer authorization", () => { + it("uses the access token unless the server requires the ID token", () => { + expect( + getBearerAuthorization({ access_token: "access", id_token: "id" }), + ).toBe("Bearer access") + expect( + getBearerAuthorization({ + access_token: "access", + id_token: "id", + groups_encoded_in_token: true, + }), + ).toBe("Bearer id") + expect( + getBearerAuthorization({ + access_token: "access", + groups_encoded_in_token: true, + }), + ).toBeUndefined() + expect(getBearerAuthorization({})).toBeUndefined() + }) +}) + +describe("OIDC refresh requests", () => { + it("passes an explicit network cancellation signal to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue({}) + vi.stubGlobal("fetch", fetchMock) + const controller = new AbortController() + + await getAuthToken( + { "acl.oidc.token.endpoint": "/token" } as Settings, + { grant_type: "refresh_token", refresh_token: "old" }, + controller.signal, + ) + + expect(fetchMock).toHaveBeenCalledWith( + "/token", + expect.objectContaining({ + method: "POST", + signal: controller.signal, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "old", + }), + }), + ) + }) + + it("preserves a successful response body that resolves after the wait deadline", async () => { + const controller = new AbortController() + let resolveBody!: (value: AuthPayload) => void + const body = new Promise((resolve) => { + resolveBody = resolve + }) + const result = readAuthTokenResponse( + { json: () => body } as Response, + controller.signal, + ) + + controller.abort() + resolveBody({ access_token: "late" } as AuthPayload) + await expect(result).resolves.toEqual({ access_token: "late" }) + }) + + it("does not apply a late OAuth error after the wait deadline", async () => { + const controller = new AbortController() + controller.abort() + await expect( + readAuthTokenResponse( + { json: () => Promise.resolve({ error: "invalid_grant" }) } as Response, + controller.signal, + ), + ).rejects.toThrow("Request timed out") + }) +}) diff --git a/src/modules/OAuth2/utils.ts b/src/modules/OAuth2/utils.ts index e1a191ef2..ecfef08d8 100644 --- a/src/modules/OAuth2/utils.ts +++ b/src/modules/OAuth2/utils.ts @@ -1,5 +1,16 @@ +import { Base64 } from "js-base64" import { Settings } from "../../providers/SettingsProvider/types" +import { AuthPayload, SignInError, TokenResponse } from "./types" +import { DeadlineExceededError, withDeadline } from "../../utils/withDeadline" import { StoreKey } from "../../utils/localStorage/types" +import { getValue } from "../../utils/localStorage" + +export const SIGN_IN_ERROR_TITLE = "Something went wrong." +export const SESSION_EXPIRED_TITLE = "Your SSO session has expired" +export const sessionRenewalError = (description?: string): SignInError => ({ + errorTitle: SESSION_EXPIRED_TITLE, + errorMessage: `${description || "Could not renew your SSO session."} Please sign in again.`, +}) type TokenPayload = Partial<{ grant_type: string @@ -62,13 +73,134 @@ export const getAuthorisationURL = ({ ) } -export const getTokenExpirationDate = (expires_in: number) => { - return new Date(new Date().getTime() + expires_in * 1000) +// Reads the exp claim of a JWT, in milliseconds. The signature is not verified: +// the claim only schedules the refresh, and the server validates the token. +const getJwtExpiration = (jwt: string | undefined) => { + const payload = jwt?.split(".")[1] + if (!payload) return undefined + try { + const claims = JSON.parse(Base64.decode(payload)) as { + exp?: unknown + } | null + const exp = claims?.exp + return typeof exp === "number" && + !Number.isNaN(new Date(exp * 1000).getTime()) + ? exp * 1000 + : undefined + } catch { + return undefined + } +} + +// expires_in is the lifetime of the access token, so an ID token bearer expires +// at its exp claim instead. exp is not rebased onto iat, because some IdPs +// (e.g. Entra ID) backdate iat by several minutes to allow for clock skew. +// Returns undefined when the bearer's expiry is unknown, because a provider may +// omit expires_in. +export const getTokenExpirationDate = ( + tokenResponse: Pick< + AuthPayload, + "id_token" | "expires_in" | "groups_encoded_in_token" + >, +): Date | undefined => { + const idTokenExpiration = tokenResponse.groups_encoded_in_token + ? getJwtExpiration(tokenResponse.id_token) + : undefined + if (idTokenExpiration !== undefined) return new Date(idTokenExpiration) + const lifetime = tokenResponse.expires_in + // Zero is a known, immediately expired lifetime, not an omitted expiry. + if (lifetime === undefined || !Number.isFinite(lifetime) || lifetime < 0) + return undefined + const expiration = new Date(Date.now() + lifetime * 1000) + return Number.isNaN(expiration.getTime()) ? undefined : expiration +} + +// The server validates the bearer; clients share this helper so stale-401 +// comparisons use exactly the same authorization value as outgoing requests. +export const getBearerAuthorization = (payload: Partial) => { + const bearer = payload.groups_encoded_in_token + ? payload.id_token + : payload.access_token + return bearer ? `Bearer ${bearer}` : undefined +} + +// SSO is tab-local and takes precedence over credentials another tab may save. +export const getStoredCredentialAuthorization = () => { + const restToken = getValue(StoreKey.REST_TOKEN) + return restToken + ? `Bearer ${restToken}` + : getValue(StoreKey.BASIC_AUTH_HEADER) || undefined +} + +export type GrantType = "authorization_code" | "refresh_token" + +const MISSING_ID_TOKEN_MESSAGES: Record = { + authorization_code: + "Your identity provider did not send the information needed to sign you in. Please ask your administrator to check the SSO configuration.", + refresh_token: + "Your identity provider did not send the information needed to keep you signed in. You can sign in again, but please ask your administrator to check the SSO configuration to prevent this happening again.", +} + +// The server cannot authenticate an ID token bearer without the ID token. OIDC +// lets a refresh response omit it, and without the openid scope there is none. +export const getMissingIdTokenError = ( + tokenResponse: Pick, + grantType: GrantType, +): SignInError | undefined => + tokenResponse.groups_encoded_in_token && !tokenResponse.id_token + ? { + errorTitle: "Missing ID token", + errorMessage: MISSING_ID_TOKEN_MESSAGES[grantType], + } + : undefined + +// Pure normalization: only the response for this grant may supply a bearer; +// only a refresh may retain the previous refresh token. Endpoint expires_at +// fields are never trusted for scheduling. +export const buildInstalledPayload = ( + response: TokenResponse, + groupsEncodedInToken: boolean | undefined, + grant: GrantType, + previous: AuthPayload | null, +): { payload: AuthPayload } | { error: SignInError } => { + if (!response.access_token) { + return { + error: + grant === "refresh_token" + ? sessionRenewalError(response.error_description) + : { + errorTitle: SIGN_IN_ERROR_TITLE, + errorMessage: + response.error_description ?? + "Could not complete the sign-in. Please try again.", + }, + } + } + const payload: AuthPayload = { + access_token: response.access_token, + id_token: response.id_token, + expires_in: response.expires_in, + token_type: response.token_type ?? "Bearer", + refresh_token: + response.refresh_token ?? + (grant === "refresh_token" ? previous?.refresh_token : undefined), + groups_encoded_in_token: groupsEncodedInToken, + } + const error = getMissingIdTokenError(payload, grant) + return error + ? { error } + : { + payload: { + ...payload, + expires_at: getTokenExpirationDate(payload)?.toISOString(), + }, + } } export const getAuthToken = async ( settings: Settings, payload: TokenPayload, + signal?: AbortSignal, ) => { return fetch( `${getBaseURL(settings)}${settings["acl.oidc.token.endpoint"]}`, @@ -78,10 +210,133 @@ export const getAuthToken = async ( "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(payload), + signal, }, ) } +// Sign-in aborts the request at this deadline. Refresh only releases waiting +// queries here; its separate network deadline allows a late rotated token. +export const TOKEN_REQUEST_TIMEOUT_MS = 10_000 + +type CodeExchange = + | { tokenResponse: TokenResponse } + | { signInError: SignInError } + +const signInFailure = (errorMessage: string): CodeExchange => ({ + signInError: { errorTitle: SIGN_IN_ERROR_TITLE, errorMessage }, +}) + +const SIGN_IN_TIMED_OUT = `The identity provider did not respond within ${TOKEN_REQUEST_TIMEOUT_MS / 1000} seconds. Please try again.` + +const requestTokens = async ( + settings: Settings, + payload: TokenPayload, + signal: AbortSignal, +): Promise => { + let response: Response + try { + response = await getAuthToken(settings, payload, signal) + } catch { + return signInFailure( + signal.aborted + ? SIGN_IN_TIMED_OUT + : "Could not reach the identity provider to complete the sign-in. Please try again.", + ) + } + try { + return { tokenResponse: await readAuthTokenResponse(response) } + } catch (error) { + // Do not log the response body: it may contain credentials. + console.error("Invalid identity-provider token response", error) + return signInFailure( + signal.aborted + ? SIGN_IN_TIMED_OUT + : response.status >= 200 && response.status < 300 + ? "The identity provider returned an invalid token response. Please ask your administrator to check the SSO configuration." + : `The identity provider returned an unexpected response (HTTP ${response.status}). Please try again.`, + ) + } +} + +// Exchanges the authorization code for tokens. A request that fails or times +// out without an OAuth response resolves with the error to show, because the +// sign-in would otherwise stop on a blank screen. The token response itself +// may still be an OAuth error. +export const exchangeAuthorizationCode = async ( + settings: Settings, + payload: TokenPayload, +): Promise => { + const controller = new AbortController() + try { + return await withDeadline( + requestTokens(settings, payload, controller.signal), + TOKEN_REQUEST_TIMEOUT_MS, + () => controller.abort(), + ) + } catch (error) { + if (error instanceof DeadlineExceededError) { + return signInFailure(SIGN_IN_TIMED_OUT) + } + throw error + } +} + +export const readAuthTokenResponse = async ( + response: Response, + deadlineSignal?: AbortSignal, +): Promise => { + let body: unknown + try { + body = await response.json() + } catch { + // JSON parser errors can include snippets of the body (and credentials). + // Keep the diagnostic safe for the sign-in logger. + throw new Error("Invalid token response: expected JSON") + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new Error("Invalid token response: expected an object") + } + const fields = body as Record + const tokenResponse: TokenResponse = {} + for (const key of [ + "access_token", + "id_token", + "refresh_token", + "token_type", + "error", + "error_description", + ] as const) { + const value = fields[key] + // Some IdPs return JSON null for omitted optional fields. + if (value === undefined || value === null) continue + if (typeof value !== "string") { + throw new Error(`Invalid token response: ${key} must be a string`) + } + tokenResponse[key] = value + } + // Entra's legacy/B2C endpoints can serialise this lifetime as a string. + // An unusable optional lifetime means unknown expiry, not invalid tokens. + const lifetime = + typeof fields.expires_in === "string" && /^\d+$/.test(fields.expires_in) + ? Number(fields.expires_in) + : fields.expires_in + if ( + typeof lifetime === "number" && + Number.isFinite(lifetime) && + lifetime >= 0 && + !Number.isNaN(new Date(Date.now() + lifetime * 1000).getTime()) + ) { + tokenResponse.expires_in = lifetime + } + // Preserve a late rotated refresh token, but don't let a late OAuth error + // log out a session whose waiting queries have already been released. + if (deadlineSignal?.aborted && !tokenResponse.access_token) { + throw new DeadlineExceededError() + } + return tokenResponse +} + export const hasUIAuth = (settings: Settings) => settings["acl.enabled"] && !settings["acl.basic.auth.realm.enabled"] diff --git a/src/modules/OAuth2/views/login.test.tsx b/src/modules/OAuth2/views/login.test.tsx new file mode 100644 index 000000000..c974e6c8a --- /dev/null +++ b/src/modules/OAuth2/views/login.test.tsx @@ -0,0 +1,166 @@ +import React from "react" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { ThemeProvider } from "styled-components" +import { afterEach, describe, expect, it, vi } from "vitest" +import { darkTheme } from "../../../theme" + +vi.mock("../../../providers", () => ({ + useSettings: () => ({ + settings: { + "release.type": "EE", + "release.version": "test", + "acl.basic.auth.realm.enabled": false, + }, + }), +})) +vi.mock("../../../components", () => ({ + Badge: "div", + Text: "span", + Button: "button", + IconButton: "button", + Form: Object.assign( + (props: React.ComponentProps<"form">) =>
, + { Item: "label", Input: "input", Submit: "button" }, + ), +})) +vi.mock("../../../components/icons", () => ({ + User: () => null, + Building: () => null, + ErrorWarning: () => null, + XSquare: () => null, +})) +vi.mock("../utils", () => ({ + SIGN_IN_ERROR_TITLE: "Sign-in failed", + getSSOUserNameWithClientID: () => "", +})) +vi.mock("../../../components/LoadingSpinner", () => ({ + LoadingSpinner: () => null, +})) +vi.mock("./loginBackground", () => ({ LoginBackground: () => null })) +import { Login } from "./login" + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.unstubAllGlobals() +}) + +const mount = (errors: { errorTitle?: string; errorMessage?: string } = {}) => { + const heading = { focus: vi.fn() } + const banner = { focus: vi.fn() } + const props = { + onOAuthLogin: vi.fn(), + onBasicAuthSuccess: vi.fn(), + resetErrors: vi.fn(), + ...errors, + } + const render = () => ( + + + + ) + act(() => { + renderer = create(render(), { + createNodeMock: ( + element: React.ReactElement>, + ) => { + if (element.type === "h2") return heading + if (element.props["data-hook"] === "sign-in-error") return banner + return null + }, + }) + }) + const clearErrors = () => { + props.errorTitle = undefined + props.errorMessage = undefined + renderer!.update(render()) + } + props.resetErrors.mockImplementation(clearErrors) + return { + heading, + banner, + props, + updateErrors: (next: typeof errors) => { + act(() => { + props.errorTitle = next.errorTitle + props.errorMessage = next.errorMessage + renderer!.update(render()) + }) + }, + } +} + +describe("login focus", () => { + it("focuses the heading on initial mount without a redirect error", () => { + const { heading, banner } = mount() + expect(heading.focus).toHaveBeenCalledOnce() + expect(banner.focus).not.toHaveBeenCalled() + }) + + it("focuses redirect errors when presented, but not the heading when cleared", () => { + const { heading, banner, updateErrors } = mount({ + errorMessage: "Sign-in cancelled", + }) + expect(banner.focus).toHaveBeenCalledOnce() + expect(heading.focus).not.toHaveBeenCalled() + + updateErrors({}) + expect(heading.focus).not.toHaveBeenCalled() + updateErrors({ errorTitle: "Another sign-in error" }) + expect(banner.focus).toHaveBeenCalledTimes(2) + }) + + it("retains explicit dismissal's heading focus", () => { + const { heading, props } = mount({ errorMessage: "Sign-in cancelled" }) + act(() => { + renderer!.root + .findByProps({ label: "Dismiss sign-in error" }) + .props.onClick() + }) + expect(props.resetErrors).toHaveBeenCalledOnce() + expect(heading.focus).toHaveBeenCalledOnce() + }) + + it("does not refocus the heading during password submission or credential rejection", async () => { + const { heading, props } = mount({ errorMessage: "Sign-in cancelled" }) + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + let pending!: Promise + act(() => { + const { onSubmit } = renderer!.root.findByType("form").props as { + onSubmit: (values: { + username: string + password: string + }) => Promise + } + pending = onSubmit({ username: "admin", password: "wrong" }) + }) + expect(props.resetErrors).toHaveBeenCalledOnce() + expect(heading.focus).not.toHaveBeenCalled() + expect( + renderer!.root.findAllByProps({ "data-hook": "sign-in-error" }), + ).toHaveLength(0) + + await act(async () => { + finish(new Response("", { status: 401 })) + await pending + }) + expect(heading.focus).not.toHaveBeenCalled() + expect( + renderer!.root + .findAllByType("span") + .some((node) => node.children.includes("Invalid username or password")), + ).toBe(true) + expect(props.onBasicAuthSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/OAuth2/views/login.tsx b/src/modules/OAuth2/views/login.tsx index 47233fc05..c9fd1cb34 100644 --- a/src/modules/OAuth2/views/login.tsx +++ b/src/modules/OAuth2/views/login.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react" +import React, { useEffect, useRef, useState } from "react" import styled, { useTheme } from "styled-components" import { User, Building } from "../../../components/icons" import { ErrorWarning } from "../../../components/icons" @@ -9,7 +9,7 @@ import { Badge, Text, Form, Button, IconButton } from "../../../components" import { setValue } from "../../../utils/localStorage" import { StoreKey } from "../../../utils/localStorage/types" import { useSettings } from "../../../providers" -import { getSSOUserNameWithClientID } from "../utils" +import { getSSOUserNameWithClientID, SIGN_IN_ERROR_TITLE } from "../utils" import { RawDqlResult } from "utils/questdb/types" import { LoadingSpinner } from "../../../components/LoadingSpinner" import { Box } from "../../../components/Box" @@ -76,6 +76,7 @@ const Container = styled.div<{ $hasRedirectError: boolean }>` font-size: 16px; ` const Title = styled.h2` + outline: none; width: 100%; color: ${({ theme }) => theme.color.contentPrimary}; font-weight: 600; @@ -296,6 +297,20 @@ export const Login = ({ : "" const version = settings["release.version"] const [loading, setLoading] = useState(false) + const redirectErrorRef = useRef(null) + const titleRef = useRef(null) + const hasFocusedInitialView = useRef(false) + + useEffect(() => { + if (redirectErrorTitle || redirectErrorMessage) { + redirectErrorRef.current?.focus() + } else if (!hasFocusedInitialView.current) { + titleRef.current?.focus() + } + // Clearing an SSO error during password submission must not steal focus + // from the form. Explicit dismissal restores heading focus separately. + hasFocusedInitialView.current = true + }, [redirectErrorTitle, redirectErrorMessage]) const httpBasicAuthStrategy = isEE ? { @@ -358,14 +373,21 @@ export const Login = ({ {(redirectErrorTitle || redirectErrorMessage) && ( - - + + - - {redirectErrorTitle ?? "Something went wrong."} - - - {redirectErrorMessage ?? "Error logging in. Please try again."} - + + + {redirectErrorTitle ?? SIGN_IN_ERROR_TITLE} + + + + + {redirectErrorMessage ?? + "Error logging in. Please try again."} + + { + resetErrors() + titleRef.current?.focus() + }} > @@ -405,7 +435,9 @@ export const Login = ({ height="48" /> - Sign in to QuestDB + + Sign in to QuestDB + {settings["acl.oidc.enabled"] && ( diff --git a/src/modules/OAuth2/views/refreshFailedWarning.test.tsx b/src/modules/OAuth2/views/refreshFailedWarning.test.tsx new file mode 100644 index 000000000..53c193db0 --- /dev/null +++ b/src/modules/OAuth2/views/refreshFailedWarning.test.tsx @@ -0,0 +1,121 @@ +import "../../../test/stubBrowserGlobals" +import React from "react" +import { ThemeProvider } from "styled-components" +import { darkTheme, lightTheme } from "../../../theme" +import { Text } from "../../../components/Text" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { RefreshFailedWarning } from "./refreshFailedWarning" + +vi.mock("../../../components/Button", () => ({ Button: "button" })) +vi.mock("../../../components/Box", () => ({ Box: "div" })) + +let renderer: ReactTestRenderer | undefined + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() +}) + +const mount = (expiresAt?: string) => { + const onSignIn = vi.fn() + act(() => { + renderer = create( + + + , + ) + }) + return onSignIn +} + +const message = () => renderer!.root.findAllByType("span")[0].children[0] +const advanceTime = (ms: number) => { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +describe("refresh failure warning", () => { + it.each([darkTheme, lightTheme])( + "uses primary content color in $mode mode", + (theme) => { + act(() => { + renderer = create( + + + , + ) + }) + expect(renderer!.root.findAllByType(Text)[0].props.color).toBe( + "contentPrimary", + ) + expect(message()).not.toContain("paused") + }, + ) + it("updates an open warning at expiry without another refresh event", () => { + const onSignIn = mount(new Date(Date.now() + 20_000).toISOString()) + expect(message()).toContain("Your current token expires at") + advanceTime(19_999) + expect(message()).toContain("Your current token expires at") + advanceTime(1) + expect(message()).toContain("Queries are not being sent. Retry them") + expect(message()).not.toContain("Your current token expires at") + act(() => { + const { onClick } = renderer!.root.findByType("button").props as { + onClick: () => void + } + onClick() + }) + expect(onSignIn).toHaveBeenCalledOnce() + }) + + it.each([undefined, "invalid", "2025-12-31T23:59:59Z"])( + "reports unsent queries immediately for expiry %s", + (expiresAt) => { + mount(expiresAt) + expect(message()).toContain("Queries are not being sent") + expect(vi.getTimerCount()).toBe(0) + }, + ) + + it("cancels its expiry timer when the warning is dismissed", () => { + mount(new Date(Date.now() + 20_000).toISOString()) + expect(vi.getTimerCount()).toBe(1) + act(() => renderer!.unmount()) + renderer = undefined + expect(vi.getTimerCount()).toBe(0) + }) + + it("reschedules if the warning receives a later expiry", () => { + mount(new Date(Date.now() + 20_000).toISOString()) + act(() => { + renderer!.update( + + + , + ) + }) + advanceTime(20_000) + expect(message()).toContain("Your current token expires at") + advanceTime(20_000) + expect(message()).toContain("Queries are not being sent") + }) + + it("does not overflow the timer for distant expiry dates", () => { + mount(new Date(Date.now() + 2_147_483_648).toISOString()) + advanceTime(2_147_483_647) + expect(message()).toContain("Your current token expires at") + advanceTime(1) + expect(message()).toContain("Queries are not being sent") + }) +}) diff --git a/src/modules/OAuth2/views/refreshFailedWarning.tsx b/src/modules/OAuth2/views/refreshFailedWarning.tsx new file mode 100644 index 000000000..8ddedea09 --- /dev/null +++ b/src/modules/OAuth2/views/refreshFailedWarning.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from "react" +import { Button } from "../../../components/Button" +import { Box } from "../../../components/Box" +import { Text } from "../../../components/Text" + +const MAX_TIMER_DELAY_MS = 2_147_483_647 + +export const RefreshFailedWarning = ({ + expiresAt, + onSignIn, +}: { + expiresAt?: string + onSignIn: () => void +}) => { + const expiry = expiresAt ? Date.parse(expiresAt) : NaN + const [expired, setExpired] = useState(() => !(expiry > Date.now())) + + useEffect(() => { + let timer: ReturnType | undefined + const updateExpiry = () => { + const remaining = expiry - Date.now() + setExpired(!(remaining > 0)) + if (remaining > 0) { + timer = setTimeout( + updateExpiry, + Math.min(remaining, MAX_TIMER_DELAY_MS), + ) + } + } + updateExpiry() + return () => clearTimeout(timer) + }, [expiry]) + + return ( + + + {expired + ? "Could not renew your SSO session. Queries are not being sent. Retry them to renew, or sign in again." + : `Could not renew your SSO session. Your current token expires at ${new Date(expiry).toLocaleTimeString()}.`} + + + Signing in again ends this session and interrupts running requests. + Statements may have executed; verify their outcomes before retrying. + + + + ) +} diff --git a/src/providers/AuthProvider.test.tsx b/src/providers/AuthProvider.test.tsx new file mode 100644 index 000000000..f87a4362f --- /dev/null +++ b/src/providers/AuthProvider.test.tsx @@ -0,0 +1,1444 @@ +import "../test/stubBrowserGlobals" +import React from "react" +import { Base64 } from "js-base64" +import { act, create, ReactTestRenderer } from "react-test-renderer" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { AuthProvider, useAuth } from "./AuthProvider" +import { QuestProvider } from "./QuestProvider" +import { questClient } from "../utils/questdb/queryClient" +import { authSessionState } from "../modules/OAuth2/authSessionState" +import { SessionEndedError } from "../modules/OAuth2/session" +import { eventBus } from "../modules/EventBus" +import { EventType } from "../modules/EventBus/types" +import { StoreKey } from "../utils/localStorage/types" +import type { Settings } from "./SettingsProvider/types" +import type { TokenResponse } from "../modules/OAuth2/types" +import { isRefreshable, tokenRefresh } from "../utils/questdb/tokenRefresh" +import { SettingsProvider, useSettings } from "./SettingsProvider" +import { ThemeProvider } from "styled-components" +import { darkTheme } from "../theme" + +const mocks = vi.hoisted(() => ({ + settings: {} as Settings, + useRealSettings: false, + toastError: vi.fn< + [ + React.ReactElement, + { autoClose: boolean; closeOnClick: boolean; toastId: string }, + ], + void + >(), + toastDismiss: vi.fn(), + reload: vi.fn(), + toastActive: vi.fn(() => false), +})) +vi.mock("./SettingsProvider", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSettings: () => { + const context = actual.useSettings() + return mocks.useRealSettings ? context : { settings: mocks.settings } + }, + } +}) +vi.mock("../components/Toast", () => ({ + NOTIFICATION_SHORTCUT_LABEL: "Alt+N", + NOTIFICATION_SHORTCUT_SCOPE: "", + REFRESH_FAILED_TOAST_ID: "sso-refresh-failed", + toast: { + error: mocks.toastError, + dismiss: mocks.toastDismiss, + isActive: mocks.toastActive, + }, +})) +vi.mock("../components/Button", () => ({ Button: "button" })) +vi.mock("../components/Text", () => ({ Text: "span" })) +vi.mock("../components/Box", () => ({ Box: "div" })) +vi.mock("react-redux", () => ({ + useDispatch: () => vi.fn(), + useSelector: () => undefined, +})) +vi.mock("../store", () => ({ + actions: { telemetry: { start: () => ({ type: "start" }) } }, + selectors: { telemetry: { getConfig: vi.fn() } }, +})) +vi.mock("../modules/ConsoleEventTracker", () => ({ + start: vi.fn(), + stop: vi.fn(), +})) +vi.mock("../components/QueryExecutionConfirmDialog", () => ({ + QueryExecutionConfirmDialog: () => null, +})) +vi.mock("../components", () => ({ + Text: "span", + Button: "button", + Box: "div", + CenteredLayout: "section", +})) +vi.mock("../modules/OAuth2/views/login", () => ({ + Login: (props: Record) => + React.createElement("div", { + ...props, + "data-hook": "mock-login", + "data-sso-username": localStorage.getItem("sso.username.test-client"), + }), +})) + +let renderer: ReactTestRenderer | undefined +let auth: ReturnType +const initialToken = { + access_token: "initial", + id_token: "initial-id", + refresh_token: "initial-refresh", + expires_in: 3600, + token_type: "Bearer", +} +const refreshToken = ( + deadline = new AbortController().signal, + request = new AbortController().signal, +) => auth.refreshAuthToken(mocks.settings, "initial-refresh", deadline, request) +let signInToken: TokenResponse = initialToken +const tokenResponse = (body: TokenResponse) => + new Response(JSON.stringify(body), { status: 200 }) +// Unsigned, as only the claims are read: the server verifies the signature. +const idToken = (exp: number) => + `header.${Base64.encodeURI(JSON.stringify({ exp }))}.signature` + +beforeEach(() => { + localStorage.clear() + signInToken = initialToken + mocks.useRealSettings = false + mocks.toastActive.mockReturnValue(false) + const tabStorage = new Map() + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => tabStorage.get(key) ?? null, + setItem: vi.fn((key: string, value: string) => tabStorage.set(key, value)), + removeItem: vi.fn((key: string) => tabStorage.delete(key)), + }) + vi.stubGlobal("location", { + origin: "http://localhost", + pathname: "/", + search: "?code=test", + href: "http://localhost/?code=test", + reload: mocks.reload, + }) + mocks.settings = { + "acl.enabled": true, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.token.endpoint": "/token", + "acl.oidc.groups.encoded.in.token": false, + } + vi.stubGlobal( + "fetch", + vi.fn((url: string) => + Promise.resolve( + url === "/token" ? tokenResponse(signInToken) : new Response(""), + ), + ), + ) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + authSessionState.endSession() + tokenRefresh.syncSession() + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +const mount = async () => { + const Consumer = () => { + auth = useAuth() + return null + } + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + // Signed in, so `auth` comes from this render rather than an earlier test. + expect( + renderer?.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + expect(auth.sessionData?.access_token).toBe("initial") +} + +describe("AuthProvider token installation", () => { + const recoverDuringRefresh = async () => { + const Consumer = () => { + auth = useAuth() + return + } + const tree = () => ( + + + + + + ) + vi.mocked(fetch).mockImplementation((url) => + Promise.resolve( + url === "/token" + ? tokenResponse(initialToken) + : new Response(JSON.stringify({ ddl: "OK" })), + ), + ) + await act(async () => { + renderer = create(tree()) + await Promise.resolve() + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + const session = authSessionState.getAuthPayload()! + const generation = authSessionState.getSessionId() + vi.spyOn(Date, "now").mockReturnValue( + Date.parse(session.expires_at!) - 20_000, + ) + vi.mocked(fetch).mockClear() + let finishRefresh!: (response: Response) => void + const pending = new Promise((resolve) => { + finishRefresh = resolve + }) + vi.mocked(fetch).mockImplementation((url) => + url === "/token" + ? pending + : Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ) + const query = questClient + .queryRaw("SELECT 99") + .catch((error: unknown) => error) + await act(async () => { + await Promise.resolve() + }) + expect(tokenRefresh.hardDeadline).toBeDefined() + const requestSignal = vi.mocked(fetch).mock.lastCall?.[1]?.signal + + // Settings error/retry removes and replaces both providers without ending + // this tab's session or cancelling its in-flight rotating refresh. + act(() => renderer!.unmount()) + location.search = "" + await act(async () => { + renderer = create(tree()) + await Promise.resolve() + }) + expect(authSessionState.getAuthPayload()).toBe(session) + expect(authSessionState.getSessionId()).toBe(generation) + expect(requestSignal?.aborted).toBe(false) + expect(fetch).toHaveBeenCalledTimes(1) + return { finishRefresh, query } + } + + it.each(["failure while unmounted", "warning removed on unmount"])( + "restores the retained refresh warning after %s", + async (phase) => { + await mount() + if (phase === "warning removed on unmount") { + authSessionState.markRefreshFailed() + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + } + act(() => renderer!.unmount()) + authSessionState.markRefreshFailed() + eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED) + mocks.toastError.mockClear() + location.search = "" + await mount() + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(mocks.toastError.mock.calls[0][1].toastId).toBe( + "sso-refresh-failed", + ) + }, + ) + + it("confirms interruption before signing in again and explains the ended work on Login", async () => { + await mount() + vi.spyOn(questClient, "hasActiveRequests").mockReturnValue(true) + vi.spyOn(questClient, "hasSentRequests").mockReturnValue(true) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining("Statements may already have executed"), + ) + expect(authSessionState.isAuthenticated()).toBe(true) + confirm.mockReturnValue(true) + act(() => auth.signInAgain()) + const login = renderer!.root.findByProps({ "data-hook": "mock-login" }) + expect(login.props.errorMessage).toContain("Verify their outcomes") + expect(authSessionState.isAuthenticated()).toBe(false) + }) + + it("does not claim execution when signing in again cancels only unsent requests", async () => { + await mount() + // A refresh waiter is active work, but it never reached the server. + vi.spyOn(questClient, "hasActiveRequests").mockReturnValue(true) + vi.spyOn(questClient, "hasSentRequests").mockReturnValue(false) + const confirm = vi.fn(() => true) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledWith( + "Signing in again cancels requests that have not been sent yet. End this session?", + ) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toBe("Your previous session has ended. Please sign in again.") + expect(authSessionState.isAuthenticated()).toBe(false) + }) + + it("signs in again without a confirmation or warning while only background reads run", async () => { + await mount() + const fetchMock = vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + vi.stubGlobal("fetch", fetchMock) + const reads = [ + questClient.showTables(), + questClient.queryRaw("query_activity()", { background: true }), + ].map((read) => read.catch((error: unknown) => error)) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).not.toHaveBeenCalled() + const message = renderer!.root.findByProps({ "data-hook": "mock-login" }) + .props.errorMessage as string + expect(message).toBe( + "Your previous session has ended. Please sign in again.", + ) + expect(message).not.toContain("statements may have executed") + await Promise.all(reads) + }) + + it("confirms a real pending CSV upload with no SQL activity before signing in again", async () => { + await mount() + expect(questClient.hasActiveRequests()).toBe(false) + let finishUpload!: () => void + const abortUpload = vi.fn() + class UploadRequest { + status = 200 + response = "{}" + abort = abortUpload + upload = { addEventListener: vi.fn() } + open = vi.fn() + setRequestHeader = vi.fn() + send = vi.fn(() => { + finishUpload = () => this.onload?.() + }) + onload?: () => void + } + vi.stubGlobal("XMLHttpRequest", UploadRequest) + const upload = questClient.uploadCSVFile({ + file: new File(["x\n1"], "probe.csv"), + name: "probe", + owner: "admin", + onProgress: vi.fn(), + }) + const rejected = expect(upload).rejects.toMatchObject({ + name: "SessionEndedError", + }) + await Promise.resolve() + await Promise.resolve() + expect(questClient.hasActiveRequests()).toBe(true) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledOnce() + expect(authSessionState.isAuthenticated()).toBe(true) + expect(abortUpload).not.toHaveBeenCalled() + confirm.mockReturnValue(true) + act(() => auth.signInAgain()) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toContain("Verify their outcomes") + expect(abortUpload).toHaveBeenCalledOnce() + await rejected + finishUpload() + expect(questClient.hasActiveRequests()).toBe(false) + }) + + it("confirms a real pending preferences write and explains its uncertainty on Login", async () => { + await mount() + expect(questClient.hasActiveRequests()).toBe(false) + let finish!: (response: Response) => void + const response = new Promise((resolve) => { + finish = resolve + }) + let writeSignal: AbortSignal | null | undefined + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.startsWith("settings?")) { + writeSignal = init?.signal + return response + } + return Promise.resolve(new Response("{}")) + }) + vi.stubGlobal("fetch", fetchMock) + const write = questClient.savePreferences({ + version: 1, + instance_name: "Changed", + }) + const rejected = expect(write).rejects.toBeInstanceOf( + // The PUT may commit, but its old-session response must not be accepted. + SessionEndedError, + ) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + expect(questClient.hasActiveRequests()).toBe(true) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledOnce() + expect(authSessionState.isAuthenticated()).toBe(true) + expect(questClient.hasActiveRequests()).toBe(true) + expect(writeSignal?.aborted).toBe(false) + confirm.mockReturnValue(true) + act(() => auth.signInAgain()) + expect(writeSignal?.aborted).toBe(true) + expect(confirm).toHaveBeenCalledTimes(2) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toContain("Verify their outcomes") + expect(authSessionState.isAuthenticated()).toBe(false) + finish(new Response("{}")) + await rejected + expect(questClient.hasActiveRequests()).toBe(false) + }) + + it("shows the mounted login when a refresh started before recovery ends the session", async () => { + const { finishRefresh, query } = await recoverDuringRefresh() + await act(async () => { + finishRefresh( + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Revoked", + }), + { status: 400 }, + ), + ) + await query + }) + expect(authSessionState.isAuthenticated()).toBe(false) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props, + ).toMatchObject({ + errorTitle: "Your SSO session has expired", + errorMessage: "Revoked Please sign in again.", + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(0) + }) + + it("keeps a rotated token when a refresh started before recovery succeeds", async () => { + const { finishRefresh, query } = await recoverDuringRefresh() + await act(async () => { + finishRefresh( + tokenResponse({ + ...initialToken, + access_token: "renewed", + refresh_token: "rotated", + }), + ) + await query + }) + expect(authSessionState.getAuthPayload()).toMatchObject({ + access_token: "renewed", + refresh_token: "rotated", + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer renewed", + }) + }) + + it.each([StoreKey.REST_TOKEN, StoreKey.BASIC_AUTH_HEADER])( + "resumes and refreshes retained SSO after recovery with another tab's %s", + async (credentialKey) => { + await mount() + const retained = auth.sessionData + const generation = authSessionState.getSessionId() + act(() => renderer?.unmount()) + localStorage.setItem(credentialKey, "another-tab") + location.search = "" + vi.mocked(fetch).mockImplementation((url) => + Promise.resolve( + url === "/token" + ? tokenResponse({ ...initialToken, access_token: "renewed" }) + : new Response(JSON.stringify({ ddl: "OK" })), + ), + ) + const Consumer = () => { + auth = useAuth() + return null + } + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(auth.sessionData).toBe(retained) + expect(authSessionState.getSessionId()).toBe(generation) + expect(questClient.refreshTokenMethod).toBeTypeOf("function") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer initial", + }) + + const expiry = Date.parse(retained!.expires_at!) + vi.spyOn(Date, "now").mockReturnValue(expiry + 1) + await act(async () => { + await questClient.queryRaw("select 1") + }) + expect(auth.sessionData?.access_token).toBe("renewed") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer renewed", + }) + expect(localStorage.getItem(credentialKey)).toBe("another-tab") + expect(authSessionState.getSessionId()).toBe(generation) + }, + ) + + it.each([ + { + mode: "password", + key: StoreKey.REST_TOKEN, + value: "password-token", + authorization: "Bearer password-token", + }, + { + mode: "password", + key: StoreKey.BASIC_AUTH_HEADER, + value: "Basic password", + authorization: "Basic password", + }, + { + mode: "browser basic", + key: undefined, + value: undefined, + authorization: undefined, + }, + { + mode: "disabled ACL", + key: undefined, + value: undefined, + authorization: undefined, + }, + ])( + "ends retained SSO when settings recovery switches to $mode ($key)", + async ({ mode, key, value, authorization }) => { + await mount() + const generation = authSessionState.getSessionId() + const oldSession = authSessionState.capture() + act(() => renderer!.unmount()) + location.search = "" + mocks.settings = { + "acl.enabled": mode !== "disabled ACL", + "acl.oidc.enabled": false, + "acl.basic.auth.realm.enabled": mode === "browser basic", + } + if (key && value) localStorage.setItem(key, value) + vi.mocked(fetch).mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ) + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + expect(authSessionState.getSessionId()).toBeGreaterThan(generation) + expect(oldSession.isCurrent()).toBe(false) + expect(questClient.refreshTokenMethod).toBeUndefined() + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + if (key) expect(localStorage.getItem(key)).toBe(value) + await questClient.queryRaw("select 99") + const headers = vi.mocked(fetch).mock.lastCall?.[1]?.headers as Record< + string, + string + > + expect(headers.Authorization).toBe(authorization) + }, + ) + + it("cancels a retained rotating refresh when recovered settings disable ACL", async () => { + const { finishRefresh, query } = await recoverDuringRefresh() + const requestSignal = vi.mocked(fetch).mock.lastCall?.[1]?.signal + authSessionState.markRefreshFailed() + act(() => renderer!.unmount()) + mocks.settings = { "acl.enabled": false } + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(requestSignal?.aborted).toBe(true) + expect(tokenRefresh.hardDeadline).toBeUndefined() + expect(authSessionState.hasRefreshFailed()).toBe(false) + await act(async () => { + finishRefresh( + tokenResponse({ + ...initialToken, + access_token: "obsolete", + refresh_token: "obsolete", + }), + ) + await query + }) + expect(authSessionState.getAuthPayload()).toBeNull() + await questClient.queryRaw("select 99") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).not.toHaveProperty( + "Authorization", + ) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + }) + + it("clears the remembered identity before an asynchronous forced logout renders Login", async () => { + await mount() + localStorage.setItem("sso.username.test-client", "previous-user") + localStorage.setItem(StoreKey.OAUTH_REDIRECT_COUNT, "5") + // Deliberately outside act's outer batch: React 17 flushes the session-end + // listener synchronously when an asynchronous SQL 401 reaches logout. + await Promise.resolve().then(() => + eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED), + ) + expect(localStorage.getItem("sso.username.test-client")).toBeNull() + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props[ + "data-sso-username" + ], + ).toBeNull() + }) + + it("warns after expiry, avoids duplicates, and restores a dismissed warning", async () => { + await mount() + authSessionState.setAuthPayload({ + ...initialToken, + expires_at: new Date(Date.now() - 1).toISOString(), + }) + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledOnce() + const warning = create(mocks.toastError.mock.calls[0][0]) + expect(warning.root.findAllByType("span")[0].children).toEqual([ + expect.stringContaining("Queries are not being sent"), + ]) + mocks.toastActive.mockReturnValue(true) + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledOnce() + mocks.toastActive.mockReturnValue(false) + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledTimes(2) + act(() => { + ;( + warning.root.findByType("button").props as { onClick: () => void } + ).onClick() + }) + expect(authSessionState.isAuthenticated()).toBe(false) + warning.unmount() + }) + + it.each(["sso", "password"])( + "retains this tab's %s login choice on reload with shared credentials", + async (method) => { + location.search = "" + localStorage.setItem(StoreKey.REST_TOKEN, "password-tab-token") + localStorage.setItem("sso.username.test-client", "sso-user") + localStorage.setItem(StoreKey.SSO_SESSION_ACTIVE, "true") + sessionStorage.setItem(StoreKey.LOGIN_METHOD, method) + mocks.settings["acl.oidc.authorization.endpoint"] = + "https://identity.example/authorize" + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe( + "password-tab-token", + ) + if (method === "sso") { + expect(location.href).toContain("https://identity.example/authorize?") + expect(new URL(location.href).searchParams.get("prompt")).toBe("none") + } else { + expect(location.href).toBe("http://localhost/?code=test") + expect( + renderer!.root.findAllByProps({ "data-hook": "live" }), + ).toHaveLength(1) + expect(authSessionState.isSSOAuthenticated()).toBe(false) + } + }, + ) + + it("preserves another tab's password credentials on both sign-in and refresh", async () => { + localStorage.setItem(StoreKey.REST_TOKEN, "stale") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic stale") + await mount() + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe("stale") + expect(localStorage.getItem(StoreKey.BASIC_AUTH_HEADER)).toBe("Basic stale") + expect(sessionStorage.getItem(StoreKey.LOGIN_METHOD)).toBe("sso") + localStorage.setItem(StoreKey.REST_TOKEN, "another-tab") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic another-tab") + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ access_token: "renewed", expires_in: 3600 }), + ) + await act(async () => { + const result = await refreshToken() + expect(result?.access_token).toBe("renewed") + // A refresh reply that doesn't rotate the token keeps the original. + expect(result?.refresh_token).toBe("initial-refresh") + }) + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe("another-tab") + expect(localStorage.getItem(StoreKey.BASIC_AUTH_HEADER)).toBe( + "Basic another-tab", + ) + }) + + it("returns no token when the server requires an ID token omitted on refresh", async () => { + mocks.settings["acl.oidc.groups.encoded.in.token"] = true + vi.spyOn(console, "warn").mockImplementation(() => {}) + await mount() + const body = { access_token: "rejected", expires_in: 3600 } + vi.mocked(fetch).mockResolvedValueOnce(tokenResponse(body)) + await act(async () => { + await expect(refreshToken()).resolves.toBeUndefined() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + expect( + renderer?.root.findByProps({ "data-hook": "mock-login" }).props + .errorTitle, + ).toBe("Missing ID token") + expect(body).not.toHaveProperty("groups_encoded_in_token") + }) + + it.each([false, true])( + "synchronizes refresh teardown on logout (reload=%s)", + async (reload) => { + await mount() + const session = authSessionState.getAuthPayload() + if (!isRefreshable(session)) + throw new Error("Expected refreshable session") + const request = new AbortController() + let requestSignal: AbortSignal | undefined + tokenRefresh.syncSession() + tokenRefresh.start(session, (_deadline, signal) => { + requestSignal = signal + return new Promise((resolve) => + request.signal.addEventListener("abort", () => resolve(undefined)), + ) + }) + await Promise.resolve() + const wait = tokenRefresh.softDeadline + expect(wait).toBeDefined() + const sync = vi.spyOn(tokenRefresh, "syncSession") + act(() => auth.logout({ reload })) + expect(sync).toHaveBeenCalledOnce() + expect(mocks.reload).toHaveBeenCalledTimes(reload ? 1 : 0) + expect(authSessionState.getAuthPayload()).toBeNull() + expect(tokenRefresh.softDeadline).toBeUndefined() + expect(requestSignal?.aborted).toBe(true) + request.abort() + await wait + }, + ) + + it("ignores an initial code response after the mounted owner's session ends", async () => { + let finish!: (response: Response) => void + vi.mocked(fetch).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + expect(finish).toBeDefined() + act(() => authSessionState.endSession()) + await act(async () => { + finish(tokenResponse(initialToken)) + await Promise.resolve() + }) + expect(authSessionState.isAuthenticated()).toBe(false) + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(1) + expect(fetch).toHaveBeenCalledOnce() + }) + + it("ignores a late success after logout instead of reinstalling the session", async () => { + await mount() + let resolve!: (response: Response) => void + vi.mocked(fetch).mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }), + ) + const refresh = refreshToken() + act(() => auth.logout()) + await act(async () => { + resolve(tokenResponse({ ...initialToken, access_token: "late" })) + await expect(refresh).resolves.toBeUndefined() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + }) + + it("keeps a late rotated token after the wait deadline without aborting fetch", async () => { + await mount() + const deadline = new AbortController() + deadline.abort() + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ + ...initialToken, + access_token: "late", + refresh_token: "rotated", + }), + ) + await act(async () => { + await expect(refreshToken(deadline.signal)).resolves.toMatchObject({ + refresh_token: "rotated", + }) + }) + const refreshCall = vi + .mocked(fetch) + .mock.calls.find( + ([url, init]) => + url === "/token" && + init?.body instanceof URLSearchParams && + init.body.get("grant_type") === "refresh_token", + ) + expect(refreshCall?.[1]?.signal?.aborted).toBe(false) + expect(authSessionState.getAuthPayload()?.refresh_token).toBe("rotated") + }) + + it.each([429, 500, 503])( + "keeps the session on JSON HTTP %s refresh failure", + async (status) => { + await mount() + const session = authSessionState.getAuthPayload() + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "temporarily_unavailable" }), { + status, + }), + ) + await expect(refreshToken()).rejects.toThrow( + `Identity provider unavailable (HTTP ${status})`, + ) + expect(authSessionState.getAuthPayload()).toBe(session) + expect( + renderer?.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + }, + ) + + it.each(["response", "body"])( + "preserves write uncertainty when automatic renewal logs out during the SQL %s", + async (phase) => { + await mount() + vi.mocked(fetch).mockClear() + let writeSignal: AbortSignal | null | undefined + const readBody = vi.fn() + vi.mocked(fetch).mockImplementation((url, init) => { + if (init?.keepalive) return Promise.resolve(new Response("{}")) + if (url === "/token") { + return Promise.resolve( + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Revoked", + }), + { status: 400 }, + ), + ) + } + writeSignal = init?.signal + const pending = new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + if (phase === "body") { + return Promise.resolve({ + ok: true, + status: 200, + text: () => { + readBody() + return pending + }, + } as Response) + } + return pending.then(() => new Response("{}")) + }) + questClient.refreshTokenMethod = (deadline, request) => + auth.refreshAuthToken( + mocks.settings, + "initial-refresh", + deadline, + request, + ) + const write = questClient + .queryRaw("INSERT INTO trades VALUES (1)") + .catch((error: unknown) => error) + await vi.waitFor(() => { + expect(questClient.hasSentRequests()).toBe(true) + if (phase === "body") expect(readBody).toHaveBeenCalledOnce() + }) + vi.spyOn(Date, "now").mockReturnValue( + Date.parse(authSessionState.getAuthPayload()!.expires_at!) - 20_000, + ) + await act(async () => { + await expect(questClient.queryRaw("tables()")).rejects.toMatchObject({ + sessionEnded: true, + requestNotSent: true, + }) + expect(await write).toMatchObject({ sessionEnded: true }) + }) + expect(writeSignal?.aborted).toBe(true) + expect(questClient.hasSentRequests()).toBe(false) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props, + ).toMatchObject({ + errorTitle: "Your SSO session has expired", + }) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toBe( + "Revoked Please sign in again. Running requests were interrupted; statements may have executed. Verify their outcomes before running them again.", + ) + expect( + vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + typeof url === "string" && + url.startsWith("exec?") && + !init?.keepalive, + ), + ).toHaveLength(1) + }, + ) + + it.each([0, 1])( + "does not warn about execution when renewal fails after %s definitively unexecuted SQL requests", + async (requests) => { + await mount() + if (!requests) { + vi.spyOn(Date, "now").mockReturnValue( + Date.parse(authSessionState.getAuthPayload()!.expires_at!) - 20_000, + ) + } + vi.mocked(fetch).mockClear() + vi.mocked(fetch).mockImplementation((url) => + Promise.resolve( + url === "/token" + ? new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Revoked", + }), + { status: 400 }, + ) + : new Response("", { status: 401 }), + ), + ) + questClient.refreshTokenMethod = (deadline, request) => + auth.refreshAuthToken( + mocks.settings, + "initial-refresh", + deadline, + request, + ) + await act(async () => { + await expect( + questClient.queryRaw("INSERT INTO trades VALUES (1)"), + ).rejects.toMatchObject({ sessionEnded: true, requestNotSent: true }) + }) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toBe("Revoked Please sign in again.") + expect( + vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + typeof url === "string" && + url.startsWith("exec?") && + !init?.keepalive, + ), + ).toHaveLength(requests) + expect(questClient.hasSentRequests()).toBe(false) + }, + ) + + it.each([false, true])( + "warns after an HTTP permission rejection only if another write is in flight: %s", + async (otherWrite) => { + await mount() + vi.mocked(fetch).mockImplementation((url, init) => { + if (init?.keepalive) return Promise.resolve(new Response("{}")) + if (typeof url === "string" && url.includes("pending_write")) { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + } + return Promise.resolve( + new Response( + JSON.stringify({ error: "Access denied to protocol [HTTP]" }), + { status: 403 }, + ), + ) + }) + const pending = otherWrite + ? questClient + .queryRaw("insert into pending_write values (1)") + .catch((error: unknown) => error) + : undefined + if (otherWrite) { + await vi.waitFor(() => expect(questClient.hasSentRequests()).toBe(true)) + } + await act(async () => { + await expect( + questClient.queryRaw("insert into trades values (1)"), + ).rejects.toMatchObject({ sessionEnded: true, requestNotSent: true }) + if (pending) expect(await pending).toMatchObject({ sessionEnded: true }) + }) + const message = renderer!.root.findByProps({ "data-hook": "mock-login" }) + .props.errorMessage as string + expect(message).toContain("You are not authorized to use the Web Console") + expect(message.includes("statements may have executed")).toBe(otherWrite) + expect(questClient.hasSentRequests()).toBe(false) + }, + ) + + it("ends the session for an explicit invalid_grant refresh error", async () => { + await mount() + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }), + ) + await act(async () => { + await expect(refreshToken()).resolves.toBeUndefined() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + }) + + it("discards an aborted request's late body even before logout", async () => { + await mount() + const session = authSessionState.getAuthPayload() + const network = new AbortController() + const deadline = new AbortController() + let finishBody!: (body: TokenResponse) => void + const body = new Promise((resolve) => { + finishBody = resolve + }) + vi.mocked(fetch).mockResolvedValueOnce({ + status: 200, + json: () => body, + } as Response) + const result = auth.refreshAuthToken( + mocks.settings, + "initial-refresh", + deadline.signal, + network.signal, + ) + await Promise.resolve() + network.abort() + finishBody({ + ...initialToken, + access_token: "obsolete", + refresh_token: "obsolete", + }) + await expect(result).resolves.toBeUndefined() + expect(authSessionState.getAuthPayload()).toBe(session) + expect(vi.mocked(fetch).mock.lastCall?.[1]?.signal).toBe(network.signal) + }) + + it("keeps public callbacks stable after successful refresh", async () => { + await mount() + const before = auth + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ ...initialToken, access_token: "renewed" }), + ) + await act(async () => { + await refreshToken() + }) + expect(auth.refreshAuthToken).toBe(before.refreshAuthToken) + expect(auth.logout).toBe(before.logout) + expect(auth.redirectToAuthorizationUrl).toBe( + before.redirectToAuthorizationUrl, + ) + }) + + it("shows a persistent consequence-aware warning and dismisses it on success or logout", async () => { + await mount() + authSessionState.markRefreshFailed() + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledWith(expect.any(Object), { + autoClose: false, + closeOnClick: false, + toastId: "sso-refresh-failed", + }) + const warning = create(mocks.toastError.mock.calls[0][0]) + expect(warning.root.findAllByType("span")[0].children).toEqual([ + expect.stringContaining("expires at"), + ]) + expect(warning.root.findByType("button").children).toEqual([ + "Sign in again", + ]) + expect(warning.root.findAllByType("span")[1].children).toEqual([ + expect.stringContaining("interrupts running requests"), + ]) + warning.unmount() + mocks.toastDismiss.mockClear() + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ ...initialToken, access_token: "renewed" }), + ) + await act(async () => { + await refreshToken() + }) + expect(mocks.toastDismiss).toHaveBeenCalledWith("sso-refresh-failed") + expect(authSessionState.hasRefreshFailed()).toBe(false) + mocks.toastDismiss.mockClear() + act(() => auth.logout()) + expect(mocks.toastDismiss).toHaveBeenCalledWith("sso-refresh-failed") + }) +}) + +describe.each([ + { + key: StoreKey.REST_TOKEN, + value: "recovered-password", + authorization: "Bearer recovered-password", + }, + { + key: StoreKey.BASIC_AUTH_HEADER, + value: "Basic recovered-password", + authorization: "Basic recovered-password", + }, +])( + "AuthProvider initial exchange ownership ($key)", + ({ key, value, authorization }) => { + it.each(["success", "OAuth error", "network error"])( + "ignores late %s after Settings Retry recovery and password login", + async (outcome) => { + mocks.useRealSettings = true + mocks.settings["acl.oidc.state.required"] = true + location.search = "?code=test&state=expected" + localStorage.setItem(StoreKey.OAUTH_STATE, "expected") + vi.stubGlobal("history", { + replaceState: (_state: unknown, _title: string, url: string) => { + location.search = new URL(url, "http://localhost").search + }, + }) + let settingsCalls = 0 + const settingsGates: Array<(response: Response) => void> = [] + let finishCode!: (response: Response) => void + let rejectCode!: (error: Error) => void + const config = () => + new Response( + JSON.stringify({ + config: mocks.settings, + preferences: {}, + "preferences.version": 1, + }), + ) + vi.mocked(fetch).mockImplementation((url) => { + if (url === "settings") { + settingsCalls++ + if (settingsCalls === 1) + return Promise.resolve(new Response("", { status: 503 })) + if (settingsCalls === 2 || settingsCalls === 3) + return new Promise((resolve) => settingsGates.push(resolve)) + return Promise.resolve(config()) + } + if (url === "warnings") return Promise.resolve(new Response("[]")) + if (url === "assets/console-configuration.json") + return Promise.resolve(new Response("{}")) + if (url === "/token") + return new Promise((resolve, reject) => { + finishCode = resolve + rejectCode = reject + }) + return Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))) + }) + const Consumer = () => { + auth = useAuth() + return + } + let reloadSettings!: ReturnType< + typeof useSettings + >["refreshSettingsAndPreferences"] + const SettingsDriver = () => { + reloadSettings = useSettings().refreshSettingsAndPreferences + return null + } + await act(async () => { + renderer = create( + + + + + + + + + + , + ) + await Promise.resolve() + }) + const retry = () => + renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Retry"))! + // A later settings refresh can still remove the Auth owner while its + // initial code exchange is pending. + await act(async () => { + retry().props.onClick() + await Promise.resolve() + }) + expect(settingsGates).toHaveLength(1) + await act(async () => { + settingsGates.shift()!(config()) + await Promise.resolve() + }) + expect(finishCode).toBeDefined() + expect(location.search).toBe("") + let settingsRefresh!: Promise + await act(async () => { + settingsRefresh = reloadSettings() + await Promise.resolve() + }) + await act(async () => { + settingsGates.shift()!(new Response("", { status: 503 })) + await settingsRefresh + await Promise.resolve() + }) + await act(async () => { + retry().props.onClick() + await Promise.resolve() + }) + const login = renderer!.root.findByProps({ "data-hook": "mock-login" }) + await act(async () => { + localStorage.setItem(key, value) + login.props.onBasicAuthSuccess() + await Promise.resolve() + }) + const recoveredSession = authSessionState.capture() + const callsBefore = vi.mocked(fetch).mock.calls.length + await act(async () => { + if (outcome === "network error") rejectCode(new Error("Offline")) + else + finishCode( + outcome === "success" + ? tokenResponse(initialToken) + : new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + }), + ) + await Promise.resolve() + }) + expect(localStorage.getItem(key)).toBe(value) + expect(authSessionState.getAuthPayload()).toBeNull() + expect(recoveredSession.isCurrent()).toBe(true) + expect(fetch).toHaveBeenCalledTimes(callsBefore) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + await questClient.queryRaw("SELECT 987") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: authorization, + }) + }, + ) + }, +) + +describe("AuthProvider bearer expiry", () => { + it.each([0, "0"])( + "renews a zero-lifetime response (%j) before sending SQL", + async (expiresIn) => { + vi.mocked(fetch).mockImplementation((url, init) => { + const grant = (init?.body as URLSearchParams | undefined)?.get( + "grant_type", + ) + return Promise.resolve( + url === "/token" + ? new Response( + JSON.stringify({ + ...initialToken, + expires_in: expiresIn, + ...(grant === "refresh_token" + ? { + access_token: "renewed", + refresh_token: "rotated", + expires_in: 3600, + } + : {}), + }), + ) + : new Response(JSON.stringify({ ddl: "OK" })), + ) + }) + const Consumer = () => { + auth = useAuth() + return + } + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(auth.sessionData).toMatchObject({ + access_token: "renewed", + refresh_token: "rotated", + }) + const refreshes = vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + url === "/token" && + (init?.body as URLSearchParams).get("grant_type") === + "refresh_token", + ) + expect(refreshes).toHaveLength(1) + expect( + (refreshes[0][1]?.body as URLSearchParams).get("refresh_token"), + ).toBe("initial-refresh") + const sqlCalls = vi + .mocked(fetch) + .mock.calls.filter( + ([url]) => + typeof url === "string" && + url.startsWith("exec?") && + !url.includes("session=true"), + ) + expect(sqlCalls.length).toBeGreaterThan(0) + for (const [, init] of sqlCalls) + expect(init?.headers).toMatchObject({ Authorization: "Bearer renewed" }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + }, + ) + + it("installs an ID token bearer without expires_in at sign-in and on refresh", async () => { + // OAuth only recommends expires_in, the access token's lifetime. + mocks.settings["acl.oidc.groups.encoded.in.token"] = true + const signInExpiry = Math.floor(Date.now() / 1000) + 600 + signInToken = { + ...initialToken, + id_token: idToken(signInExpiry), + expires_in: undefined, + } + await mount() + expect(auth.sessionData?.expires_at).toBe( + new Date(signInExpiry * 1000).toISOString(), + ) + + const refreshExpiry = signInExpiry + 600 + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ + access_token: "renewed", + id_token: idToken(refreshExpiry), + token_type: "Bearer", + }), + ) + await act(async () => { + await expect(refreshToken()).resolves.toMatchObject({ + id_token: idToken(refreshExpiry), + expires_at: new Date(refreshExpiry * 1000).toISOString(), + }) + }) + expect(authSessionState.getAuthPayload()?.id_token).toBe( + idToken(refreshExpiry), + ) + }) + + it.each([ + { bearer: "access token", groupsInToken: false, id_token: "initial-id" }, + { bearer: "ID token", groupsInToken: true, id_token: "not-a-jwt" }, + ])( + "signs in with an $bearer bearer of unknown expiry, leaving it unscheduled", + async ({ groupsInToken, id_token }) => { + // A provider without lifetimes wants the token used until it is rejected. + mocks.settings["acl.oidc.groups.encoded.in.token"] = groupsInToken + // A provider's extra field is deliberately outside TokenResponse. + const wireResponse = { + ...initialToken, + id_token, + expires_in: undefined, + expires_at: "2000-01-01T00:00:00.000Z", + } + signInToken = wireResponse + await mount() + expect(auth.sessionData?.expires_at).toBeUndefined() + expect(authSessionState.getAuthPayload()?.expires_at).toBeUndefined() + }, + ) + + it("installs a refreshed token of unknown expiry", async () => { + await mount() + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ access_token: "renewed", token_type: "Bearer" }), + ) + await act(async () => { + await expect(refreshToken()).resolves.toMatchObject({ + access_token: "renewed", + expires_at: undefined, + }) + }) + expect(authSessionState.getAuthPayload()?.access_token).toBe("renewed") + expect(authSessionState.getAuthPayload()?.expires_at).toBeUndefined() + }) +}) diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index bbb86c27d..29f79ebdd 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -2,18 +2,30 @@ import React, { createContext, useContext, useEffect, + useMemo, useReducer, + useRef, useState, } from "react" -import { AuthPayload } from "../modules/OAuth2/types" +import { unstable_batchedUpdates } from "react-dom" +import { + AuthPayload, + LogoutOptions, + TokenResponse, +} from "../modules/OAuth2/types" import { StoreKey } from "../utils/localStorage/types" import { getValue, removeValue, setValue } from "../utils/localStorage" import { getAuthorisationURL, getAuthToken, + exchangeAuthorizationCode, + readAuthTokenResponse, getSSOUserNameWithClientID, - getTokenExpirationDate, + buildInstalledPayload, + getBearerAuthorization, removeSSOUserNameWithClientID, + GrantType, + SIGN_IN_ERROR_TITLE, } from "../modules/OAuth2/utils" import { generateCodeChallenge, @@ -26,29 +38,22 @@ import { ErrorResult } from "../utils" import { Login } from "../modules/OAuth2/views/login" import { Settings } from "./SettingsProvider/types" import { useSettings } from "./SettingsProvider" -import { ssoAuthState } from "../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../modules/OAuth2/authSessionState" +import { handleSSOUnauthorized } from "../modules/OAuth2/handleUnauthorized" +import { toast, REFRESH_FAILED_TOAST_ID } from "../components/Toast" +import { questClient } from "../utils/questdb/queryClient" +import { RefreshFailedWarning } from "../modules/OAuth2/views/refreshFailedWarning" type ContextProps = { - sessionData?: Partial - logout: ({ - promptForLogin, - errorTitle, - errorMessage, - isDisconnection, - reload, - clearSSOSession, - }?: { - promptForLogin?: boolean - errorTitle?: string - errorMessage?: string - isDisconnection?: boolean - reload?: boolean - clearSSOSession?: boolean - }) => void + sessionData?: AuthPayload + logout: (options?: LogoutOptions) => void + signInAgain: () => void refreshAuthToken: ( settings: Settings, refreshToken: string | undefined, - ) => Promise + deadlineSignal: AbortSignal, + requestSignal: AbortSignal, + ) => Promise redirectToAuthorizationUrl: (options?: { prompt?: "login" | "none" }) => void } @@ -77,7 +82,8 @@ const initialState: { const defaultValues: ContextProps = { sessionData: undefined, logout: () => {}, - refreshAuthToken: () => Promise.resolve({} as AuthPayload), + signInAgain: () => {}, + refreshAuthToken: () => Promise.resolve(undefined), redirectToAuthorizationUrl: () => {}, } @@ -106,72 +112,140 @@ const stripOAuthCallbackParams = (search: string) => { return params } +// Replaces the callback params with the ones saved before the redirect to the +// provider, whatever the outcome of the sign-in. Otherwise a reload resubmits +// the used code, and its invalid_grant error replaces the actual sign-in error. +const restoreUrlAfterOAuthCallback = () => { + if (!history.replaceState) return + const restored = stripOAuthCallbackParams(window.location.search) + const savedParams = sessionStorage.getItem(StoreKey.OAUTH_RETURN_PARAMS) + sessionStorage.removeItem(StoreKey.OAUTH_RETURN_PARAMS) + if (savedParams) { + new URLSearchParams(savedParams).forEach((value, key) => { + if (!restored.has(key)) restored.set(key, value) + }) + } + const queryString = restored.toString() + history.replaceState( + null, + "", + location.pathname + (queryString ? `?${queryString}` : ""), + ) +} + const getDefaultRedirectUri = () => window.location.origin + window.location.pathname export const AuthContext = createContext(defaultValues) -const reducer = (s: State, n: Partial) => ({ ...s, ...n }) +const reducer = (s: State, n: Partial) => + Object.entries(n).every(([key, value]) => s[key as keyof State] === value) + ? s + : { ...s, ...n } export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const { settings } = useSettings() + const callbacks = useRef< + Omit & { + redirectToAuthorizationUrl: (options?: { + prompt?: "login" | "none" + isRetry?: boolean + }) => void + } + >(defaultValues) const [sessionData, setSessionData] = useState(undefined) const [state, dispatch] = useReducer(reducer, initialState) + const unsubscribeRef = useRef void>>([]) + const subscribe = (type: EventType, handler: (payload?: T) => void) => { + eventBus.subscribe(type, handler) + unsubscribeRef.current.push(() => eventBus.unsubscribe(type, handler)) + } - const setAuthToken = (tokenResponse: AuthPayload, settings: Settings) => { - if (tokenResponse.access_token) { - tokenResponse.groups_encoded_in_token = - settings["acl.oidc.groups.encoded.in.token"] - tokenResponse.expires_at = getTokenExpirationDate( - tokenResponse.expires_in, - ).toString() // convert from the sec offset - ssoAuthState.setAuthPayload(tokenResponse) - setSessionData(tokenResponse) - setValue(StoreKey.SSO_SESSION_ACTIVE, "true") - // Remove the code from the URL - if (history.replaceState) { - const restored = stripOAuthCallbackParams(window.location.search) - const savedParams = sessionStorage.getItem(StoreKey.OAUTH_RETURN_PARAMS) - sessionStorage.removeItem(StoreKey.OAUTH_RETURN_PARAMS) - if (savedParams) { - new URLSearchParams(savedParams).forEach((value, key) => { - if (!restored.has(key)) restored.set(key, value) - }) - } - const queryString = restored.toString() - history.replaceState( - null, - "", - location.pathname + (queryString ? `?${queryString}` : ""), - ) - } - } else { - const error = tokenResponse as unknown as OAuth2Error - // display error message - logout({ - errorTitle: "Something went wrong.", - errorMessage: - error.error_description ?? "Error logging in. Please try again.", - }) + // Returns only a fully installed token, never an error or rejected bearer. + const setAuthToken = ( + tokenResponse: TokenResponse, + settings: Settings, + grantType: GrantType, + ): AuthPayload | undefined => { + const result = buildInstalledPayload( + tokenResponse, + settings["acl.oidc.groups.encoded.in.token"], + grantType, + authSessionState.getAuthPayload(), + ) + if ("error" in result) { + callbacks.current.logout(result.error) + return undefined } + const installed = result.payload + toast.dismiss(REFRESH_FAILED_TOAST_ID) + authSessionState.setAuthPayload(installed) + // Login choice is tab-local; never erase another tab's password credentials. + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "sso") + // React 17 does not batch promise callbacks automatically. + unstable_batchedUpdates(() => { + setSessionData(installed) + dispatch({ view: View.ready }) + }) + setValue(StoreKey.SSO_SESSION_ACTIVE, "true") + return installed } const refreshAuthToken = async ( settings: Settings, refreshToken: string | undefined, + deadlineSignal: AbortSignal, + requestSignal: AbortSignal, ) => { - const response = await getAuthToken(settings, { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: settings["acl.oidc.client.id"], - }) - const tokenResponse = (await response.json()) as AuthPayload - setAuthToken(tokenResponse, settings) - return tokenResponse + const payloadAtStart = authSessionState.getAuthPayload() + const response = await getAuthToken( + settings, + { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: settings["acl.oidc.client.id"], + }, + requestSignal, + ) + // Transient HTTP failures take the same backoff path as transport failures, + // regardless of whether their bodies are JSON or HTML. + if (response.status === 429 || response.status >= 500) { + throw new Error(`Identity provider unavailable (HTTP ${response.status})`) + } + // The wait signal marks late errors, not network cancellation. Preserve a + // late rotated token until the separate request deadline expires. + const tokenResponse = await readAuthTokenResponse(response, deadlineSignal) + if ( + requestSignal.aborted || + !payloadAtStart || + authSessionState.getAuthPayload() !== payloadAtStart + ) { + return undefined + } + return setAuthToken(tokenResponse, settings, "refresh_token") } - const setupOAuth2 = async (settings: Settings) => { + const showRefreshFailedWarning = () => { + const payload = authSessionState.getAuthPayload() + if (!payload || toast.isActive(REFRESH_FAILED_TOAST_ID)) return + toast.error( + callbacks.current.signInAgain()} + />, + { + autoClose: false, + closeOnClick: false, + toastId: REFRESH_FAILED_TOAST_ID, + }, + ) + } + + const setupOAuth2 = async ( + settings: Settings, + isCurrentOwner: () => boolean, + ) => { if (!settings["acl.enabled"]) { dispatch({ view: View.ready }) return @@ -179,36 +253,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { // Proceed with the OAuth2 flow only if it is enabled on the server if (settings["acl.oidc.enabled"]) { - // Subscribe for any subsequent REST 401 responses (incorrect token, etc) - eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { - const oauthRedirectCount = getValue(StoreKey.OAUTH_REDIRECT_COUNT) - // If any prior 401 when oauth2 is enabled has been received - if (oauthRedirectCount) { - const count = parseInt(oauthRedirectCount) - // Something is wrong with the backend, it is consistently rejecting the token. - // Redirect to a dedicated logout page instead to break the loop. - if (!isNaN(count) && count >= 5) { - // redirect to /logout and force user authentication to avoid infinite loop - removeValue(StoreKey.OAUTH_REDIRECT_COUNT) - logout({ promptForLogin: true, clearSSOSession: true }) - } else { - setValue( - StoreKey.OAUTH_REDIRECT_COUNT, - JSON.stringify( - oauthRedirectCount ? parseInt(oauthRedirectCount) + 1 : 1, - ), - ) - logout() - } - // First time 401 - } else { - setValue(StoreKey.OAUTH_REDIRECT_COUNT, JSON.stringify(1)) - logout() - } + subscribe(EventType.MSG_AUTH_REFRESH_FAILED, showRefreshFailedWarning) + + subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { + handleSSOUnauthorized((options) => callbacks.current.logout(options)) }) // Clear the 401 auth redirect loop in case the connection is back to normal - eventBus.subscribe(EventType.MSG_CONNECTION_OK, () => { + subscribe(EventType.MSG_CONNECTION_OK, () => { removeValue(StoreKey.OAUTH_REDIRECT_COUNT) }) @@ -217,6 +269,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { : "" const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get("code") + const retainedPayload = authSessionState.getAuthPayload() const oauth2Error = new OAuth2Error( urlParams.get("error"), urlParams.get("error_description"), @@ -227,18 +280,20 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const state = getValue(StoreKey.OAUTH_STATE) const stateParam = urlParams.get("state") removeValue(StoreKey.OAUTH_STATE) + restoreUrlAfterOAuthCallback() if ( settings["acl.oidc.state.required"] && (!state || state !== stateParam) ) { // the callback cannot be tied to a login this browser started - logout({ promptForLogin: true }) + callbacks.current.logout({ promptForLogin: true }) return } const code_verifier = getValue(StoreKey.PKCE_CODE_VERIFIER) - const response = await getAuthToken(settings, { + const session = authSessionState.capture() + const exchange = await exchangeAuthorizationCode(settings, { grant_type: "authorization_code", code, code_verifier, @@ -246,9 +301,23 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { redirect_uri: settings["acl.oidc.redirect.uri"] || getDefaultRedirectUri(), }) - const tokenResponse = (await response.json()) as AuthPayload - setAuthToken(tokenResponse, settings) - await startServerSession(tokenResponse) + // Unlike rotating refreshes, an initial exchange belongs only to the + // provider and login that started it. Settings recovery or logout may + // have replaced that owner while its token response was pending. + if (!isCurrentOwner() || !session.isCurrent()) return + if ("signInError" in exchange) { + callbacks.current.logout(exchange.signInError) + return + } + const { tokenResponse } = exchange + const installed = setAuthToken( + tokenResponse, + settings, + "authorization_code", + ) + if (installed) { + await startServerSession(installed) + } } else if (oauth2Error.error) { // User has just been redirected back from the OAuth2 provider and there is an error const previousPrompt = getValue(StoreKey.OAUTH_PROMPT) @@ -256,23 +325,35 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { if (previousPrompt === "none") { // If we requested authorization code silently (prompt=none), it could be that the user // does not have an active SSO session, so let's send the user to re-authenticate (prompt=login) - redirectToAuthorizationUrl({ isRetry: true }) + callbacks.current.redirectToAuthorizationUrl({ isRetry: true }) } else { // If the error is not in response for a silent authorization code request, display the error - logout({ - errorTitle: "Something went wrong.", + restoreUrlAfterOAuthCallback() + callbacks.current.logout({ + errorTitle: SIGN_IN_ERROR_TITLE, errorMessage: oauth2Error.error + ": " + oauth2Error.error_description, }) } + } else if (retainedPayload) { + // Settings recovery can remount the providers without ending this + // tab's SSO session. Resume its refresh capability as well as its bearer. + unstable_batchedUpdates(() => { + setSessionData(retainedPayload) + dispatch({ view: View.ready }) + }) + if (authSessionState.hasRefreshFailed()) showRefreshFailedWarning() } else if ( + sessionStorage.getItem(StoreKey.LOGIN_METHOD) !== "password" && ssoUsername && getValue(StoreKey.SSO_SESSION_ACTIVE) && - !getValue(StoreKey.REST_TOKEN) + (sessionStorage.getItem(StoreKey.LOGIN_METHOD) === "sso" || + (!getValue(StoreKey.REST_TOKEN) && + !getValue(StoreKey.BASIC_AUTH_HEADER))) ) { - // No REST token, so it is a page reload for an SSO user - // who didn't explicitly log out — try to request a token silently - redirectToAuthorizationUrl({ prompt: "none" }) + // Resume this tab's SSO choice even if another tab stored password + // credentials. Otherwise only try silent SSO when none are stored. + callbacks.current.redirectToAuthorizationUrl({ prompt: "none" }) } else { // Stop loading and display the login state uiAuthLogin() @@ -281,8 +362,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { await basicAuthLogin() } else { // Subscribe for any subsequent REST 401 responses (incorrect token, etc) - eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { - logout() + subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { + if (authSessionState.isAuthenticated()) callbacks.current.logout() }) //username + pwd via login page @@ -294,6 +375,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { // run a simple query to force basic auth by browser const response = await fetch(`exec?query=select 42`) if (response.status === 200) { + authSessionState.markNonSSOAuthenticated() dispatch({ view: View.ready }) } else { await basicAuthLogin() @@ -302,10 +384,10 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const startServerSession = async (tokenResponse: AuthPayload) => { // execute a simple query with session=true + const authorization = getBearerAuthorization(tokenResponse) + if (!authorization) return await fetch(`exec?query=select 2&session=true`, { - headers: { - Authorization: `Bearer ${tokenResponse.groups_encoded_in_token ? tokenResponse.id_token : tokenResponse.access_token}`, - }, + headers: { Authorization: authorization }, }) } @@ -322,6 +404,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const token = getValue(StoreKey.REST_TOKEN) const basicAuthHeader = getValue(StoreKey.BASIC_AUTH_HEADER) if (token || basicAuthHeader) { + authSessionState.markNonSSOAuthenticated() + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "password") dispatch({ view: View.ready }) } else { // Stop loading and display the login state @@ -360,25 +444,25 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }) } - const logout = ({ - promptForLogin, - errorTitle, - errorMessage, - isDisconnection, - reload, - clearSSOSession, - }: { - promptForLogin?: boolean - errorTitle?: string - errorMessage?: string - isDisconnection?: boolean - reload?: boolean - clearSSOSession?: boolean - } = {}) => { - ssoAuthState.clearAuthPayload() - setSessionData(undefined) + const logout = (options: LogoutOptions = {}) => { + const { promptForLogin, reload, clearSSOSession } = options + // Capture uncertainty before session-end listeners abort and forget work. + const logoutOptions = questClient.hasSentRequests() + ? { + ...options, + errorMessage: [ + options.errorMessage, + "Running requests were interrupted; statements may have executed. Verify their outcomes before running them again.", + ] + .filter(Boolean) + .join(" "), + } + : options + toast.dismiss(REFRESH_FAILED_TOAST_ID) + // Finish storage cleanup before session-end listeners can render Login. removeValue(StoreKey.OAUTH_PROMPT) sessionStorage.removeItem(StoreKey.OAUTH_RETURN_PARAMS) + sessionStorage.removeItem(StoreKey.LOGIN_METHOD) removeValue(StoreKey.REST_TOKEN) removeValue(StoreKey.BASIC_AUTH_HEADER) if (clearSSOSession) { @@ -387,31 +471,57 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { if (promptForLogin && settings["acl.oidc.client.id"]) { removeSSOUserNameWithClientID(settings["acl.oidc.client.id"]) } + // Session-end listeners clear client credentials, pending confirmations, + // refresh work and telemetry timers even when this document does not reload. + // React 17 does not batch promise continuations. Resetting the store and + // showing Login must be one commit, before console effects can run again. + unstable_batchedUpdates(() => authSessionState.endSession(logoutOptions)) destroyServerSession() if (reload) { window.location.reload() - return } - dispatch({ view: View.login, errorTitle, errorMessage, isDisconnection }) } useEffect(() => { - if (sessionData) { - dispatch({ view: View.ready }) + let active = true + // Settings recovery may change the server's authentication mode. End the + // old SSO lifecycle before subscribing this owner or reading password + // credentials, without deleting those credentials through logout(). + if ( + authSessionState.isSSOAuthenticated() && + (!settings["acl.enabled"] || !settings["acl.oidc.enabled"]) + ) { + unstable_batchedUpdates(() => authSessionState.endSession()) } - }, [sessionData]) - - useEffect(() => { + // A rotating refresh can outlive Settings recovery and its original auth + // owner. Shared teardown must notify the mounted owner, not only the + // provider instance that started the refresh. Do not recursively log out. + unsubscribeRef.current.push( + authSessionState.onEnd( + ({ errorTitle, errorMessage, isDisconnection, reload } = {}) => { + if (reload) return + unstable_batchedUpdates(() => { + setSessionData(undefined) + dispatch({ + view: View.login, + errorTitle, + errorMessage, + isDisconnection, + }) + }) + }, + ), + ) // If user has no access to the HTTP protocol, they will receive a 403 error response // containing a specific message type. If that happens, we display a full screen error page. - eventBus.subscribe( + subscribe( EventType.MSG_CONNECTION_FORBIDDEN, (errorPayload) => { if ( errorPayload && errorPayload.error.match(/Access denied.* \[HTTP]/gm) ) { - logout({ + callbacks.current.logout({ errorTitle: "Oops. You've been disconnected.", errorMessage: "You are not authorized to use the Web Console. You may try login again or contact your account administrator.", @@ -421,20 +531,65 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }, ) - void setupOAuth2(settings) + void setupOAuth2(settings, () => active) + return () => { + active = false + unsubscribeRef.current.forEach((unsubscribe) => unsubscribe()) + unsubscribeRef.current = [] + toast.dismiss(REFRESH_FAILED_TOAST_ID) + } }, []) + const signInAgain = () => { + // Requests still waiting for renewal were never sent, so cancelling them + // cannot leave a statement half-done. Only a sent request is uncertain. + const sent = questClient.hasSentRequests() + if ( + questClient.hasActiveRequests() && + !window.confirm( + sent + ? "Signing in again interrupts running requests. Statements may already have executed. End this session?" + : "Signing in again cancels requests that have not been sent yet. End this session?", + ) + ) + return + // logout() explains interrupted sent requests itself. + callbacks.current.logout({ + clearSSOSession: true, + errorTitle: "Sign in again", + errorMessage: sent + ? "Your previous session has ended." + : "Your previous session has ended. Please sign in again.", + }) + } + + // Stable public callbacks delegate to this render's implementation. + callbacks.current = { + logout, + signInAgain, + refreshAuthToken, + redirectToAuthorizationUrl, + } + const stableCallbacks = useMemo>( + () => ({ + logout: (options) => callbacks.current.logout(options), + signInAgain: () => callbacks.current.signInAgain(), + refreshAuthToken: (...args) => + callbacks.current.refreshAuthToken(...args), + redirectToAuthorizationUrl: (options) => + callbacks.current.redirectToAuthorizationUrl(options), + }), + [], + ) + const contextValue = useMemo( + () => ({ sessionData, ...stableCallbacks }), + [sessionData, stableCallbacks], + ) + const views: { [key in View]: () => React.ReactNode } = { [View.loading]: () => null, [View.ready]: () => ( - + {children} ), @@ -446,6 +601,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }) }} onBasicAuthSuccess={() => { + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "password") + authSessionState.markNonSSOAuthenticated() dispatch({ view: View.ready }) }} resetErrors={() => diff --git a/src/providers/QuestProvider/AuthCheckRecovery.tsx b/src/providers/QuestProvider/AuthCheckRecovery.tsx new file mode 100644 index 000000000..c3803c6c7 --- /dev/null +++ b/src/providers/QuestProvider/AuthCheckRecovery.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import { ConnectionErrorView } from "../../components/ConnectionErrorView" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { + endedFor, + isAuthRefreshUnavailable, +} from "../../modules/OAuth2/session" +import { isCancelledByUser } from "../../utils/questdb/cancellation" +import { questClient } from "../../utils/questdb/queryClient" +import type { ErrorResult } from "../../utils/questdb/types" + +export type AuthCheckFailure = { message: string; renewalUnavailable: boolean } + +export const useAuthCheck = (required: boolean) => { + const [finished, setFinished] = useState(!required) + const [error, setError] = useState() + const [pending, setPending] = useState(false) + const inFlight = useRef(false) + const mounted = useRef(false) + const consoleRef = useRef(null) + const focusAfterRetry = useRef(false) + + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + useEffect(() => { + if (!finished || !focusAfterRetry.current || !consoleRef.current) return + focusAfterRetry.current = false + const main = consoleRef.current + // A permanent tabindex makes blank clicks anywhere in the console focus + // main, breaking native keyboard scrolling in nested, non-focusable panels. + main.setAttribute("tabindex", "-1") + const releaseFocusTarget = () => main.removeAttribute("tabindex") + main.addEventListener("blur", releaseFocusTarget, { once: true }) + main.focus() + return () => { + main.removeEventListener("blur", releaseFocusTarget) + releaseFocusTarget() + } + }, [finished]) + + const finish = useCallback(async () => { + if (inFlight.current) return + inFlight.current = true + const session = authSessionState.capture() + setPending(true) + try { + await questClient.queryRaw("SELECT 1", { background: true }) + if (mounted.current && session.isCurrent()) setFinished(true) + } catch (failure) { + if ( + mounted.current && + !endedFor(session, failure) && + !isCancelledByUser(failure) + ) { + const renewalUnavailable = isAuthRefreshUnavailable(failure) + setError({ + renewalUnavailable, + message: renewalUnavailable + ? "The identity provider could not renew your SSO session. Retry to renew it, or sign in again." + : (failure as Partial)?.error || + "Could not connect to QuestDB.", + }) + } + } finally { + inFlight.current = false + if (mounted.current && session.isCurrent()) setPending(false) + } + }, []) + + const retry = () => { + if (pending) return + focusAfterRetry.current = true + void finish() + } + + return { finished, error, pending, finish, retry, consoleRef } +} + +export const AuthCheckRecovery = ({ + error, + pending, + onRetry, + onSignIn, +}: { + error: AuthCheckFailure + pending: boolean + onRetry: () => void + onSignIn: () => void +}) => ( + +) diff --git a/src/providers/QuestProvider/index.test.tsx b/src/providers/QuestProvider/index.test.tsx new file mode 100644 index 000000000..a0f82b582 --- /dev/null +++ b/src/providers/QuestProvider/index.test.tsx @@ -0,0 +1,586 @@ +import "../../test/stubBrowserGlobals" +import React, { useContext } from "react" +import { ThemeProvider } from "styled-components" +import { darkTheme } from "../../theme" +import { AuthRefreshUnavailableError } from "../../modules/OAuth2/session" +import { act, create, ReactTestRenderer } from "react-test-renderer" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { StoreKey } from "../../utils/localStorage/types" +import type { AuthPayload } from "../../modules/OAuth2/types" +import type { Settings } from "../SettingsProvider/types" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { questClient } from "../../utils/questdb/queryClient" + +const mocks = vi.hoisted(() => ({ + sessionData: undefined as AuthPayload | undefined, + refresh: vi.fn(), + queryRaw: vi.fn(), + headers: vi.fn<[Record], void>(), + dispatch: vi.fn(), + headingFocus: vi.fn(), + mainFocus: vi.fn(), + signInAgain: vi.fn(), + mainSetAttribute: vi.fn(), + mainRemoveAttribute: vi.fn(), + mainAddEventListener: vi.fn<[string, () => void, unknown?], void>(), + settings: { + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + } as Settings, +})) +vi.mock("../AuthProvider", () => ({ + useAuth: () => ({ + sessionData: mocks.sessionData, + refreshAuthToken: mocks.refresh, + signInAgain: mocks.signInAgain, + }), +})) +vi.mock("../SettingsProvider", () => ({ + useSettings: () => ({ settings: mocks.settings }), +})) +vi.mock("../../utils/questdb/queryClient", () => ({ + questClient: { + queryRaw: mocks.queryRaw, + setCommonHeaders: mocks.headers, + resetCredentials: () => { + mocks.headers({}) + questClient.refreshTokenMethod = undefined + }, + refreshTokenMethod: undefined, + }, +})) +vi.mock("../../utils/questdb", () => ({ + QueryExecutionManager: vi.fn(), + Type: { DQL: "dql" }, +})) +vi.mock("react-redux", () => ({ + useDispatch: () => mocks.dispatch, + useSelector: () => undefined, +})) +vi.mock("../../store", () => ({ + actions: { telemetry: { start: () => ({ type: "start" }) } }, + selectors: { telemetry: { getConfig: vi.fn() } }, +})) +vi.mock("../../modules/ConsoleEventTracker", () => ({ + start: vi.fn(), + stop: vi.fn(), +})) +vi.mock("../../components/QueryExecutionConfirmDialog", () => ({ + QueryExecutionConfirmDialog: () => null, +})) +vi.mock("../../components", () => ({ + Text: "span", + Button: "button", + CenteredLayout: "section", + Box: "div", +})) +vi.mock("../../utils/questdb/queryExecution", () => ({ questExecution: {} })) +vi.mock("../../components/Box", () => ({ + Box: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), +})) + +import { QuestContext, QuestProvider as ActualQuestProvider } from "." +const QuestProvider = ({ children }: React.PropsWithChildren) => ( + + {children} + +) + +let renderer: ReactTestRenderer | undefined +beforeEach(() => { + mocks.sessionData = undefined + mocks.settings = { + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + } + mocks.queryRaw.mockReset().mockResolvedValue({ type: "dml" }) + mocks.headers.mockClear() + localStorage.clear() + authSessionState.endSession() +}) +afterEach(() => { + act(() => renderer?.unmount()) + vi.clearAllMocks() + vi.useRealTimers() +}) +const mount = async (children =
) => { + await act(async () => { + renderer = create({children}, { + createNodeMock: (element) => ({ + focus: element.type === "h1" ? mocks.headingFocus : mocks.mainFocus, + setAttribute: mocks.mainSetAttribute, + removeAttribute: mocks.mainRemoveAttribute, + addEventListener: mocks.mainAddEventListener, + removeEventListener: vi.fn(), + }), + }) + await Promise.resolve() + }) +} + +describe("QuestProvider authentication gate", () => { + it.each([ + new AuthRefreshUnavailableError(), + { + error: "renewal unavailable", + authRefreshUnavailable: true, + requestNotSent: true, + }, + ])( + "explains an IdP renewal failure and offers sign-in independently of the toast", + async (failure) => { + mocks.queryRaw.mockRejectedValue(failure) + await mount() + expect(renderer!.root.findByType("h1").children).toEqual([ + "Could not renew your SSO session", + ]) + const signIn = renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Sign in again"))! + const onSignIn = signIn.props.onClick as () => void + act(onSignIn) + expect(mocks.signInAgain).toHaveBeenCalledOnce() + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toEqual([expect.stringContaining("No request was sent")]) + }, + ) + + it("shows a delayed status while an auth check waits, then removes it on completion", async () => { + vi.useFakeTimers() + let finish!: (result: { type: string }) => void + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? new Promise((resolve) => { + finish = resolve + }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0) + act(() => { + vi.advanceTimersByTime(500) + }) + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toEqual(["Checking your session…"]) + await act(async () => { + finish({ type: "dml" }) + await Promise.resolve() + }) + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0) + expect(renderer!.root.findByType("main").props.tabIndex).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + it("reads a rotated refresh token when the installed refresh method runs", async () => { + mocks.sessionData = { + access_token: "old", + refresh_token: "r1", + token_type: "Bearer", + } + authSessionState.setAuthPayload(mocks.sessionData) + await mount() + const refresh = questClient.refreshTokenMethod! + authSessionState.setAuthPayload({ + ...mocks.sessionData, + refresh_token: "r2", + }) + const deadline = new AbortController().signal + const network = new AbortController().signal + await refresh(deadline, network) + expect(mocks.refresh).toHaveBeenLastCalledWith( + mocks.settings, + "r2", + deadline, + network, + ) + }) + + it("releases password headers and refresh callbacks on unmount", async () => { + localStorage.setItem(StoreKey.REST_TOKEN, "private") + await mount() + act(() => renderer?.unmount()) + expect(mocks.headers).toHaveBeenLastCalledWith({}) + expect(questClient.refreshTokenMethod).toBeUndefined() + }) + + it("focuses recovery and then the console after a successful Retry", async () => { + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "Offline" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + expect(mocks.headingFocus).toHaveBeenCalledOnce() + mocks.mainFocus.mockClear() + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + renderer!.root.findByType("button").props.onClick() + await Promise.resolve() + }) + expect(mocks.mainFocus).toHaveBeenCalledOnce() + expect(mocks.mainSetAttribute).toHaveBeenCalledWith("tabindex", "-1") + expect(renderer!.root.findByType("main").props.tabIndex).toBeUndefined() + const release = mocks.mainAddEventListener.mock.calls.find( + ([event]) => event === "blur", + )![1] + release() + expect(mocks.mainRemoveAttribute).toHaveBeenCalledWith("tabindex") + }) + + it.each([StoreKey.REST_TOKEN, StoreKey.BASIC_AUTH_HEADER])( + "does not let retained %s overwrite SSO credentials", + async (key) => { + // These are deliberately NOT cleared by AuthProvider in this test. + localStorage.setItem(key, "other-tab") + mocks.sessionData = { access_token: "sso", token_type: "Bearer" } + await mount() + expect(localStorage.getItem(key)).toBe("other-tab") + expect( + mocks.headers.mock.calls.every( + ([headers]) => headers.Authorization === undefined, + ), + ).toBe(true) + }, + ) + + describe.each([ + { + key: StoreKey.REST_TOKEN, + credential: "first-user", + authorization: "Bearer first-user", + replacement: "other-user", + replacementAuthorization: "Bearer other-user", + }, + { + key: StoreKey.BASIC_AUTH_HEADER, + credential: "Basic first-user", + authorization: "Basic first-user", + replacement: "Basic other-user", + replacementAuthorization: "Basic other-user", + }, + ])( + "$key session snapshot", + ({ + key, + credential, + authorization, + replacement, + replacementAuthorization, + }) => { + it.each(["replacement", "removal"])( + "keeps its credentials after another tab's %s during the initial check", + async (change) => { + let finishAuthCheck!: (result: { type: string }) => void + const authCheck = new Promise<{ type: string }>((resolve) => { + finishAuthCheck = resolve + }) + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" ? authCheck : Promise.resolve({ type: "dml" }), + ) + localStorage.setItem(key, credential) + await mount() + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: authorization, + }) + expect( + renderer?.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(0) + expect(mocks.dispatch).not.toHaveBeenCalled() + + if (change === "replacement") { + localStorage.setItem(key, replacement) + } else { + localStorage.removeItem(key) + } + await act(async () => { + finishAuthCheck({ type: "dml" }) + await Promise.resolve() + }) + + expect( + renderer?.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: authorization, + }) + expect(mocks.dispatch).toHaveBeenCalledTimes(1) + expect(mocks.dispatch).toHaveBeenCalledWith({ type: "start" }) + expect(mocks.headers.mock.invocationCallOrder[0]).toBeLessThan( + mocks.dispatch.mock.invocationCallOrder[0], + ) + }, + ) + + it("keeps its credentials when settings change", async () => { + localStorage.setItem(key, credential) + await mount() + localStorage.setItem(key, replacement) + mocks.settings = { ...mocks.settings, "release.version": "updated" } + await act(async () => { + renderer?.update( + +
+ , + ) + await Promise.resolve() + }) + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: authorization, + }) + }) + + it("captures the current credentials on a new provider mount", async () => { + localStorage.setItem(key, credential) + await mount() + localStorage.setItem(key, replacement) + act(() => renderer?.unmount()) + await mount() + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: replacementAuthorization, + }) + }) + }, + ) + + it("fetches build information after Retry succeeds", async () => { + const versions: string[] = [] + const Consumer = () => { + versions.push(useContext(QuestContext).buildVersion.version ?? "") + return null + } + mocks.queryRaw.mockRejectedValue({ error: "Offline" }) + await mount() + expect(mocks.queryRaw).not.toHaveBeenCalledWith( + "select build", + expect.anything(), + ) + mocks.queryRaw.mockImplementation((query: string) => + Promise.resolve( + query === "select build" + ? { + type: "dql", + count: 1, + dataset: [ + [ + "Build Information: QuestDB Enterprise 9.0.0, Commit Hash abc123", + ], + ], + } + : { type: "dml" }, + ), + ) + await act(async () => { + renderer!.root.findByType("button").props.onClick() + await Promise.resolve() + }) + expect(mocks.queryRaw).toHaveBeenCalledWith("select build", { + limit: "0,1000", + background: true, + }) + expect(versions.some((version) => version.length > 0)).toBe(true) + }) + + it("renders a recoverable error when SELECT 1 fails", async () => { + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "QuestDB is not reachable [502]" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + expect( + renderer?.root.findByProps({ role: "alert" }).findByType("span").children, + ).toEqual(["QuestDB is not reachable [502]"]) + expect( + renderer?.root.findByProps({ role: "alert" }).findByType("span").props + .color, + ).toBe("statusDanger") + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + renderer?.root.findByType("button").props.onClick() + await Promise.resolve() + }) + expect( + renderer?.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + }) + + it("keeps the recovery panel and retry button mounted while retrying", async () => { + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "QuestDB is not reachable [502]" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + const button = renderer!.root.findByType("button") + let failRetry!: (error: { error: string }) => void + const retry = new Promise((_, reject) => { + failRetry = reject + }) + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" ? retry : Promise.resolve({ type: "dml" }), + ) + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + + expect(renderer!.root.findByType("button")).toBe(button) + expect(button.props.disabled).not.toBe(true) + expect(button.props["aria-disabled"]).toBe(true) + expect(button.props["aria-busy"]).toBe(true) + expect(renderer!.root.findByProps({ role: "alert" })).toBeDefined() + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toContain("Reconnecting…") + const attempts = mocks.queryRaw.mock.calls.length + act(() => { + button.props.onClick() + }) + expect(mocks.queryRaw).toHaveBeenCalledTimes(attempts) + expect( + renderer!.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(0) + + await act(async () => { + failRetry({ error: "Still unavailable" }) + await Promise.resolve() + }) + expect(renderer!.root.findByType("button")).toBe(button) + expect(button.props["aria-disabled"]).toBe(false) + expect(button.props["aria-busy"]).toBe(false) + expect( + renderer!.root.findByProps({ role: "alert" }).findByType("span").children, + ).toEqual(["Still unavailable"]) + + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + }) + + it("does not start another probe when credentials refresh during a retry", async () => { + mocks.sessionData = { access_token: "old", token_type: "Bearer" } + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "QuestDB is not reachable [502]" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + let failRetry!: (error: { error: string }) => void + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? new Promise((_, reject) => { + failRetry = reject + }) + : Promise.resolve({ type: "dml" }), + ) + const button = renderer!.root.findByType("button") + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + mocks.sessionData = { access_token: "refreshed", token_type: "Bearer" } + await act(async () => { + renderer!.update( + +
+ , + ) + await Promise.resolve() + }) + expect( + mocks.queryRaw.mock.calls.filter(([query]) => query === "SELECT 1"), + ).toHaveLength(2) + expect(button.props["aria-busy"]).toBe(true) + expect(mocks.headers).toHaveBeenLastCalledWith({}) + await act(async () => { + failRetry({ error: "QuestDB is not reachable [502]" }) + await Promise.resolve() + }) + expect(button.props["aria-busy"]).toBe(false) + }) + + it("announces consecutive identical retry failures", async () => { + const error = { error: "QuestDB is not reachable [502]" } + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject(error) + : Promise.resolve({ type: "dml" }), + ) + await mount() + const alert = renderer!.root.findByProps({ role: "alert" }) + const button = renderer!.root.findByType("button") + let reject!: (error: { error: string }) => void + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? new Promise((_, rejectQuery) => { + reject = rejectQuery + }) + : Promise.resolve({ type: "dml" }), + ) + const status = renderer!.root.findByProps({ role: "status" }) + for (let attempt = 0; attempt < 2; attempt++) { + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + expect(status.findByType("span").children).toEqual(["Reconnecting…"]) + await act(async () => { + reject(error) + await Promise.resolve() + }) + expect(renderer!.root.findByProps({ role: "alert" })).toBe(alert) + expect(alert.findByType("span").children).toEqual([error.error]) + expect(renderer!.root.findByProps({ role: "status" })).toBe(status) + expect(status.findByType("span").children).toEqual([ + "Connection failed. Retry connection.", + ]) + expect(renderer!.root.findByType("button")).toBe(button) + expect(button.props["aria-busy"]).toBe(false) + } + }) + + it("does not persist session-ended errors into the authentication gate", async () => { + mocks.queryRaw.mockRejectedValue({ + error: "Your session has ended", + sessionEnded: true, + }) + await mount() + expect(renderer?.root.findAllByProps({ role: "alert" })).toHaveLength(0) + }) + + it("keeps QuestContext stable and skips SELECT 1 on token refresh", async () => { + const values: Array> = [] + const Consumer = () => { + values.push(useContext(QuestContext)) + return null + } + mocks.sessionData = { access_token: "first", token_type: "Bearer" } + const child = + await mount(child) + const value = values[values.length - 1] + const count = mocks.queryRaw.mock.calls.filter( + ([q]) => q === "SELECT 1", + ).length + mocks.sessionData = { access_token: "refreshed", token_type: "Bearer" } + await act(async () => { + renderer?.update({child}) + await Promise.resolve() + }) + expect(values[values.length - 1]).toBe(value) + expect( + mocks.queryRaw.mock.calls.filter(([q]) => q === "SELECT 1"), + ).toHaveLength(count) + expect(mocks.headers).toHaveBeenLastCalledWith({}) + }) +}) diff --git a/src/providers/QuestProvider/index.tsx b/src/providers/QuestProvider/index.tsx index c06971be3..1c74e8d4c 100644 --- a/src/providers/QuestProvider/index.tsx +++ b/src/providers/QuestProvider/index.tsx @@ -22,15 +22,20 @@ * ******************************************************************************/ -import React, { createContext, useEffect, useState } from "react" +import React, { createContext, useEffect, useMemo, useState } from "react" +import styled from "styled-components" import * as QuestDB from "../../utils/questdb" import { useAuth } from "../AuthProvider" -import { AuthPayload } from "../../modules/OAuth2/types" -import { getValue } from "../../utils/localStorage" -import { StoreKey } from "../../utils/localStorage/types" +import { questClient } from "../../utils/questdb/queryClient" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { questExecution } from "../../utils/questdb/queryExecution" +import { AuthCheckRecovery, useAuthCheck } from "./AuthCheckRecovery" import { formatCommitHash, formatVersion } from "./services" import { Versions } from "./types" -import { hasUIAuth } from "../../modules/OAuth2/utils" +import { + getStoredCredentialAuthorization, + hasUIAuth, +} from "../../modules/OAuth2/utils" import { useSettings } from "../SettingsProvider" import { useDispatch, useSelector } from "react-redux" import { actions, selectors } from "../../store" @@ -39,9 +44,15 @@ import { stop as stopConsoleEvents, } from "../../modules/ConsoleEventTracker" import { QueryExecutionConfirmDialog } from "../../components/QueryExecutionConfirmDialog" +import { CenteredLayout, Text } from "../../components" -const questClient = new QuestDB.Client() -const questExecution = new QuestDB.QueryExecutionManager() +const ConsoleMain = styled.main` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + outline: none; +` type ContextProps = { quest: QuestDB.Client @@ -65,72 +76,80 @@ export const QuestContext = createContext(defaultValues) export const QuestProvider: React.FC = ({ children }) => { const dispatch = useDispatch() const { settings } = useSettings() - const { sessionData, refreshAuthToken } = useAuth() - const telemetryConfig = useSelector(selectors.telemetry.getConfig) - const [authCheckFinished, setAuthCheckFinished] = useState( - !hasUIAuth(settings), + const { sessionData, refreshAuthToken, signInAgain } = useAuth() + // Password credentials are shared across browser tabs; retain this tab's login. + const [nonSSOAuthorization] = useState(() => + getStoredCredentialAuthorization(), ) + const telemetryConfig = useSelector(selectors.telemetry.getConfig) + const { + finished: authCheckFinished, + error: authCheckError, + pending: authCheckPending, + finish: finishAuthCheck, + retry, + consoleRef, + } = useAuthCheck(!!hasUIAuth(settings)) + const [showAuthCheckStatus, setShowAuthCheckStatus] = useState(false) + useEffect(() => { + setShowAuthCheckStatus(false) + if (!authCheckPending) return + const timer = setTimeout(() => setShowAuthCheckStatus(true), 500) + return () => clearTimeout(timer) + }, [authCheckPending]) const [buildVersion, setBuildVersion] = useState( defaultValues.buildVersion, ) const [commitHash, setCommitHash] = useState("") - const finishAuthCheck = async () => { - // The initial check tells us if the user has permission to use the HTTP protocol. - // If not, this will trigger a full-screen error page, preventing the subsequent requests to be made. - await questClient.queryRaw("SELECT 1") - - setAuthCheckFinished(true) - } - - const setupClient = (sessionData: Partial) => { - questClient.setCommonHeaders({ - Authorization: `Bearer ${sessionData.groups_encoded_in_token ? sessionData.id_token : sessionData.access_token}`, - }) - - questClient.refreshTokenMethod = () => { - return refreshAuthToken(settings, sessionData.refresh_token) - } - - void finishAuthCheck() - } + useEffect( + () => () => { + questClient.resetCredentials() + }, + [], + ) - // User has been logged in with OAuth2 useEffect(() => { - if (sessionData) { - setupClient(sessionData) - } - }, [sessionData]) + // SSO bearer headers are derived from the installed payload at fetch time. + const authorization = sessionData ? undefined : nonSSOAuthorization + questClient.setCommonHeaders( + authorization ? { Authorization: authorization } : {}, + ) + questClient.refreshTokenMethod = sessionData + ? (deadlineSignal, requestSignal) => + refreshAuthToken( + settings, + authSessionState.getAuthPayload()?.refresh_token, + deadlineSignal, + requestSignal, + ) + : undefined + if (!authCheckFinished) void finishAuthCheck() + }, [ + sessionData, + nonSSOAuthorization, + settings, + refreshAuthToken, + authCheckFinished, + finishAuthCheck, + ]) useEffect(() => { - const restToken = getValue(StoreKey.REST_TOKEN) - // User has provided the basic auth credentials - if (restToken) { - questClient.setCommonHeaders({ - Authorization: `Bearer ${restToken}`, - }) - void finishAuthCheck() - } else { - const basicAuth = getValue(StoreKey.BASIC_AUTH_HEADER) - if (basicAuth) { - questClient.setCommonHeaders({ - Authorization: basicAuth, - }) - void finishAuthCheck() - } - } - + if (!authCheckFinished) return // TODO: Remove this, use info from `/settings` (`type` and `version`) and run this hook on `settings` dep // Get the build version info void questClient - .queryRaw("select build", { limit: "0,1000" }) + .queryRaw("select build", { limit: "0,1000", background: true }) .then((result) => { if (result.type === QuestDB.Type.DQL && result.count === 1) { setBuildVersion(formatVersion(result.dataset[0][0] as string)) setCommitHash(formatCommitHash(result.dataset[0][0] as string)) } }) - }, []) + .catch(() => { + // Authentication can end while the initial build query is waiting. + }) + }, [authCheckFinished]) // Telemetry queries use SQL, and therefore need to have auth header set if needed. // Defer starting until the authorization is properly set for all HTTP requests. @@ -149,19 +168,38 @@ export const QuestProvider: React.FC = ({ children }) => { } }, [telemetryConfig?.enabled, telemetryConfig?.id]) - if (!authCheckFinished) return null + const contextValue = useMemo( + () => ({ + quest: questClient, + questExecution, + buildVersion, + commitHash, + }), + [buildVersion, commitHash], + ) + + if (!authCheckFinished) + return authCheckError ? ( + + ) : showAuthCheckStatus ? ( + +
+ Checking your session… +
+
+ ) : null return ( - - {children} - + + + {children} + + ) } diff --git a/src/scenes/Editor/Metrics/metric.tsx b/src/scenes/Editor/Metrics/metric.tsx index e51c4063c..c70b6bc44 100644 --- a/src/scenes/Editor/Metrics/metric.tsx +++ b/src/scenes/Editor/Metrics/metric.tsx @@ -125,6 +125,7 @@ export const Metric = ({ to: toIso, }), ), + { background: true }, ), ]) diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts index ee988e222..5e1f7252a 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts @@ -230,6 +230,7 @@ describe("CellRefreshEngine", () => { "select 1", expect.any(AbortSignal), 10_000, + { background: true }, ) const state = engine.getState("c1") expect(state?.settledKey).toBe(state?.queriesKey) @@ -694,6 +695,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.anything(), expect.anything(), + { background: true }, ) expect(engine.getState("c1")?.fetchCancelled).toBe(false) }) @@ -1247,6 +1249,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) }) @@ -1745,6 +1748,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) }) @@ -2566,6 +2570,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) }) @@ -2610,6 +2615,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) // And the debounce expiry does not fetch a second time diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts index 9c41a0574..50e38c068 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -97,11 +97,15 @@ export type CellFetchState = { fetchCancelled: boolean } +// Refreshes only run statements the write gate classified as reads. +const REFRESH_READ = { background: true } + export type CellRefreshDeps = { executeSingle: ( sql: string, signal?: AbortSignal, limit?: number, + options?: { background?: boolean }, ) => Promise validateWithGlobals: ( sql: string, @@ -1264,7 +1268,8 @@ export class CellRefreshEngine { const previous = carriedResults[index] if (previous) return Promise.resolve(toExecResult(previous)) return this.limitRequest( - () => deps.executeSingle(q, ac.signal, NOTEBOOK_ROW_CAP), + () => + deps.executeSingle(q, ac.signal, NOTEBOOK_ROW_CAP, REFRESH_READ), ac.signal, ).catch((e) => errorExecResult(q, e)) }), @@ -1404,6 +1409,7 @@ export class CellRefreshEngine { queries[index], slotAbort.signal, NOTEBOOK_ROW_CAP, + REFRESH_READ, ), slotAbort.signal, ) diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 2f79255b4..c977b2bd2 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -271,6 +271,23 @@ describe("isUnverifiableExecError", () => { } }) + it("flags session-ended errors independently of their message", () => { + expect( + isUnverifiableExecError({ + type: "error", + error: "The query confirmation belongs to an ended session", + sessionEnded: true, + }), + ).toBe(true) + expect( + isUnverifiableExecError({ + type: "error", + error: "table does not exist", + sessionEnded: false, + }), + ).toBe(false) + }) + it("does NOT flag a real server error (definitively did not commit)", () => { expect( isUnverifiableExecError({ diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index fd3301cf4..095712505 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -1,4 +1,10 @@ import type { QueryExecResult } from "../../../hooks/useQueryExecution" +import { + isSessionEnded, + sessionFailureOf, + type SessionFailure, +} from "../../../modules/OAuth2/session" +import { CANCELLED_BY_USER } from "../../../utils/questdb/cancellation" import type { AgentCellView, AutoRefresh, @@ -286,7 +292,10 @@ export const singleResultFromExec = ( fetchedAt: Date.now(), } case "error": - return errorResult(query, exec.error ?? "Unknown error") + return { + ...errorResult(query, exec.error ?? "Unknown error"), + ...sessionFailureOf(exec), + } default: return { type: exec.type, query, fetchedAt: Date.now() } } @@ -346,20 +355,24 @@ export const capResultBytes = ( export { sqlHash } const UNVERIFIABLE_ERROR_MARKERS = [ - "Cancelled by user", + CANCELLED_BY_USER, "An error occurred, please try again", "Failed to read response", "Invalid JSON response from the server", "QuestDB is not reachable", ] -export const isUnverifiableExecError = (exec: { - type: string - error?: string -}): boolean => +export const isUnverifiableExecError = ( + exec: { + type: string + error?: string + } & SessionFailure, +): boolean => exec.type === "error" && - exec.error !== undefined && - UNVERIFIABLE_ERROR_MARKERS.some((m) => exec.error?.includes(m) ?? false) + !exec.requestNotSent && + (isSessionEnded(exec) || + (exec.error !== undefined && + UNVERIFIABLE_ERROR_MARKERS.some((m) => exec.error?.includes(m) ?? false))) export const UNVERIFIED_RUN_NOTE = "Run outcome unverified: the request did not return a confirmation, so the " + diff --git a/src/scenes/Import/ImportCSVFiles/index.tsx b/src/scenes/Import/ImportCSVFiles/index.tsx index f70e8b97d..6c772a871 100644 --- a/src/scenes/Import/ImportCSVFiles/index.tsx +++ b/src/scenes/Import/ImportCSVFiles/index.tsx @@ -22,7 +22,7 @@ import { import { Upload } from "./upload" import { getValue } from "../../../utils/localStorage" import { StoreKey } from "../../../utils/localStorage/types" -import { ssoAuthState } from "../../../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../../../modules/OAuth2/authSessionState" import { trackEvent } from "../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" @@ -56,11 +56,13 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { try { let ownedByNames: string[] = [] - const userResult = await quest.query(`select current_user()`) + const userResult = await quest.query(`select current_user()`, { + background: true, + }) if (userResult.type === "dql" && userResult.count > 0) { const username = Object.values(userResult.data[0])[0] as string if (username) { - const ssoAuthenticated = ssoAuthState.isSSOAuthenticated() + const ssoAuthenticated = authSessionState.isSSOAuthenticated() if (!ssoAuthenticated) { // no OAuth2 payload, non-SSO users can set themselves as owner ownedByNames.push(username) @@ -68,6 +70,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { const result = await quest.query( `show groups '${username}'`, + { background: true }, ) if (result.type === "dql" && result.count > 0) { const groups = result.data.map( diff --git a/src/scenes/Import/ImportCSVFiles/upload.tsx b/src/scenes/Import/ImportCSVFiles/upload.tsx index 7428a2d4b..a2a1ca381 100644 --- a/src/scenes/Import/ImportCSVFiles/upload.tsx +++ b/src/scenes/Import/ImportCSVFiles/upload.tsx @@ -58,6 +58,7 @@ export const Upload = ({ files, onFilesDropped, dialogOpen }: Props) => { try { const result = await quest.query( `(show parameters) where property_path ilike 'cairo.sql.copy.root'`, + { background: true }, ) if (result.type === "dql" && result.count > 0) { setCopyEnabled( diff --git a/src/scenes/Result/ResultChart/useChartQuery.ts b/src/scenes/Result/ResultChart/useChartQuery.ts index 1008f06d0..8290b74b2 100644 --- a/src/scenes/Result/ResultChart/useChartQuery.ts +++ b/src/scenes/Result/ResultChart/useChartQuery.ts @@ -108,6 +108,7 @@ export const useChartQuery = ({ seed, enabled }: Params): ChartQuery => { count: true, src: "vis", cancellable: true, + background: true, }) activeQueryIdRef.current = queryId let superseded = false diff --git a/src/scenes/Result/index.tsx b/src/scenes/Result/index.tsx index 879b1f5ef..058b7edce 100644 --- a/src/scenes/Result/index.tsx +++ b/src/scenes/Result/index.tsx @@ -199,6 +199,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const result = await quest.queryRaw(sql, { limit: `${lo},${hi}`, nm: true, + background: true, }) if (result.type === QuestDB.Type.DQL) { rendererFn(result) diff --git a/src/scenes/Schema/SuspensionDialog/index.tsx b/src/scenes/Schema/SuspensionDialog/index.tsx index eb49450d4..92b567d12 100644 --- a/src/scenes/Schema/SuspensionDialog/index.tsx +++ b/src/scenes/Schema/SuspensionDialog/index.tsx @@ -112,6 +112,7 @@ export const SuspensionDialog = ({ const escapedName = QuestDB.escapeSqlLiteral(tableName) const response = await quest.query( `wal_tables() WHERE name = '${escapedName}'`, + { background: true }, ) if (response.type === QuestDB.Type.DQL && response.data.length > 0) { setWalTableData(response.data[0]) diff --git a/src/scenes/Schema/index.tsx b/src/scenes/Schema/index.tsx index b692e9b03..0087210cc 100644 --- a/src/scenes/Schema/index.tsx +++ b/src/scenes/Schema/index.tsx @@ -232,8 +232,10 @@ const Schema = ({ for (const query of queries) { try { - const response = - await quest.query(query) + const response = await quest.query( + query, + { background: true }, + ) if (response?.type === QuestDB.Type.DQL) { dispatch(actions.query.setColumns(response.data)) diff --git a/src/store/Telemetry/epics.test.ts b/src/store/Telemetry/epics.test.ts index 022de1b4b..31a93a946 100644 --- a/src/store/Telemetry/epics.test.ts +++ b/src/store/Telemetry/epics.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { of, Subject } from "rxjs" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { AuthRefreshUnavailableError } from "../../modules/OAuth2/session" import { ActionsObservable, StateObservable } from "redux-observable" import { TelemetryAT, @@ -12,12 +14,14 @@ import { // Hoist mock functions so they're available when vi.mock runs const { mockQueryRaw, + mockQuery, mockFromFetch, mockGetRemoteConfig, mockGetConfig, mockSetRemoteConfig, } = vi.hoisted(() => ({ mockQueryRaw: vi.fn(), + mockQuery: vi.fn(), mockFromFetch: vi.fn(), mockGetRemoteConfig: vi.fn(), mockGetConfig: vi.fn(), @@ -27,6 +31,10 @@ const { })), })) +vi.mock("../../utils/questdb/queryClient", () => ({ + questClient: { queryRaw: mockQueryRaw, query: mockQuery }, +})) + vi.mock("../../utils/questdb", () => ({ Client: vi.fn().mockImplementation(() => ({ queryRaw: mockQueryRaw, @@ -54,6 +62,10 @@ vi.mock("../../consts", () => ({ vi.mock("../../store", () => ({ actions: { telemetry: { + setConfig: (config: TelemetryConfigShape) => ({ + type: TelemetryAT.SET_CONFIG, + payload: config, + }), setRemoteConfig: (config: TelemetryRemoteConfigShape) => mockSetRemoteConfig(config), }, @@ -68,30 +80,229 @@ vi.mock("../../store", () => ({ }, })) -vi.mock("../../utils/localStorage", () => ({ - getValue: vi.fn(), -})) - -vi.mock("../../modules/OAuth2/ssoAuthState", () => ({ - ssoAuthState: { - getAuthPayload: vi.fn(() => null), - }, -})) - vi.mock("../../utils/telemetry", () => ({ sendServerInfoTelemetry: vi.fn(), getTelemetryTimestamp: vi.fn(() => of({ error: false, data: {} })), })) // Import after mocks are set up -import { startTelemetry } from "./epics" +import { getServerInfo, startTelemetry } from "./epics" import * as QuestDB from "../../utils/questdb" describe("startTelemetry epic", () => { + it("cancels a scheduled poll at logout but accepts the next login's config", async () => { + vi.useFakeTimers() + authSessionState.markNonSSOAuthenticated() + mockGetRemoteConfig.mockReturnValue({}) + const actions$ = new Subject() + const state$ = new StateObservable(new Subject(), {}) + const next = vi.fn() + const sub = startTelemetry( + new ActionsObservable(actions$), + state$ as StateObservable, + undefined, + ).subscribe(next) + try { + actions$.next({ type: TelemetryAT.SET_REMOTE_CONFIG, payload: {} }) + await vi.advanceTimersByTimeAsync(1000) + authSessionState.endSession() + await vi.advanceTimersByTimeAsync(3_600_000) + expect(next).not.toHaveBeenCalled() + authSessionState.markNonSSOAuthenticated() + actions$.next({ type: TelemetryAT.SET_REMOTE_CONFIG, payload: {} }) + await vi.advanceTimersByTimeAsync(3_600_000) + expect(next).toHaveBeenCalledOnce() + } finally { + sub.unsubscribe() + authSessionState.endSession() + vi.useRealTimers() + } + }) + beforeEach(() => { vi.clearAllMocks() }) + it.each([ + new Error("connection lost"), + new AuthRefreshUnavailableError(), + { + type: "error", + error: new AuthRefreshUnavailableError().message, + authRefreshUnavailable: true, + requestNotSent: true, + query: "telemetry", + position: -1, + }, + ])( + "preserves its cursor and schedules another check after SQL rejection: %j", + async (failure) => { + vi.useFakeTimers() + try { + const remoteConfig = { lastUpdated: "2024-01-01T00:00:00.000Z" } + mockGetRemoteConfig.mockReturnValue(remoteConfig) + mockQueryRaw.mockRejectedValueOnce(failure) + const action$ = of({ + type: TelemetryAT.SET_REMOTE_CONFIG, + payload: remoteConfig, + }) + const state$ = new StateObservable(new Subject(), {}) + const next = vi.fn() + const error = vi.fn() + const sub = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ).subscribe({ next, error }) + await vi.advanceTimersByTimeAsync(3_600_000) + expect(mockQueryRaw).toHaveBeenCalledWith( + expect.stringContaining("FROM telemetry"), + { + refreshAuth: false, + background: true, + }, + ) + expect(error).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ payload: remoteConfig }), + ) + sub.unsubscribe() + } finally { + vi.useRealTimers() + } + }, + ) + + it.each([ + { status: 401, error: "Unauthorized" }, + { + sessionEnded: true, + error: "Your session has ended. Please sign in again.", + }, + ])( + "stops authentication-failure retries but accepts a fresh config: %j", + async (failure) => { + vi.useFakeTimers() + const actions$ = new Subject() + const state$ = new StateObservable(new Subject(), {}) + const remoteConfig = { lastUpdated: "2024-01-01T00:00:00.000Z" } + mockGetRemoteConfig.mockReturnValue(remoteConfig) + mockQueryRaw + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce({ type: QuestDB.Type.DQL, count: 0 }) + const next = vi.fn((action: StoreAction) => actions$.next(action)) + const error = vi.fn() + const sub = startTelemetry( + new ActionsObservable(actions$), + state$ as StateObservable, + undefined, + ).subscribe({ next, error }) + try { + actions$.next({ + type: TelemetryAT.SET_REMOTE_CONFIG, + payload: remoteConfig, + }) + await vi.advanceTimersByTimeAsync(3 * 3_600_000) + + expect(mockQueryRaw).toHaveBeenCalledTimes(1) + expect(next).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + + // A later login can restart the loop without recreating the root epic. + actions$.next({ + type: TelemetryAT.SET_REMOTE_CONFIG, + payload: remoteConfig, + }) + await vi.advanceTimersByTimeAsync(0) + expect(mockQueryRaw).toHaveBeenCalledTimes(2) + expect(error).not.toHaveBeenCalled() + } finally { + sub.unsubscribe() + vi.useRealTimers() + } + }, + ) + + it("cancels an existing hourly continuation on authentication rejection", async () => { + vi.useFakeTimers() + const actions$ = new Subject() + const state$ = new StateObservable(new Subject(), {}) + const remoteConfig = { lastUpdated: "2024-01-01T00:00:00.000Z" } + mockGetRemoteConfig.mockReturnValue(remoteConfig) + mockQueryRaw + .mockResolvedValueOnce({ type: QuestDB.Type.DQL, count: 0 }) + .mockRejectedValueOnce({ status: 401, error: "Unauthorized" }) + const next = vi.fn((action: StoreAction) => actions$.next(action)) + const error = vi.fn() + const sub = startTelemetry( + new ActionsObservable(actions$), + state$ as StateObservable, + undefined, + ).subscribe({ next, error }) + const action: StoreAction = { + type: TelemetryAT.SET_REMOTE_CONFIG, + payload: remoteConfig, + } + try { + actions$.next(action) + await vi.advanceTimersByTimeAsync(0) + actions$.next(action) + await vi.advanceTimersByTimeAsync(3 * 3_600_000) + + expect(mockQueryRaw).toHaveBeenCalledTimes(2) + expect(next).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + } finally { + sub.unsubscribe() + vi.useRealTimers() + } + }) + + it.each([new Error("session ended"), new AuthRefreshUnavailableError()])( + "keeps getServerInfo alive for the next START after a rejected query: %j", + async (failure) => { + const actions$ = new Subject() + const state$ = new StateObservable(new Subject(), {}) + mockQuery.mockRejectedValueOnce(failure).mockResolvedValueOnce({ + type: QuestDB.Type.DQL, + data: [{ id: "recovered" }], + }) + const next = vi.fn() + const error = vi.fn() + const sub = getServerInfo( + new ActionsObservable(actions$), + state$ as StateObservable, + undefined, + ).subscribe({ next, error }) + actions$.next({ type: TelemetryAT.START }) + await new Promise((resolve) => setTimeout(resolve, 0)) + actions$.next({ type: TelemetryAT.START }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockQuery).toHaveBeenCalledTimes(2) + expect(mockQuery).toHaveBeenNthCalledWith( + 1, + "telemetry_config limit -1", + { + refreshAuth: false, + background: true, + }, + ) + expect(mockQuery).toHaveBeenNthCalledWith( + 2, + "telemetry_config limit -1", + { + refreshAuth: false, + background: true, + }, + ) + expect(error).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ payload: { id: "recovered" } }), + ) + sub.unsubscribe() + }, + ) + it("should schedule next check when lastUpdated is missing (skip path)", async () => { mockGetRemoteConfig.mockReturnValue(undefined) diff --git a/src/store/Telemetry/epics.ts b/src/store/Telemetry/epics.ts index c9bb73589..7cf54eb31 100644 --- a/src/store/Telemetry/epics.ts +++ b/src/store/Telemetry/epics.ts @@ -33,8 +33,24 @@ import { catchError, scan, delayWhen, + takeUntil, } from "rxjs/operators" -import { defer, from, NEVER, of, timer, throwError } from "rxjs" +import { + defer, + EMPTY, + from, + NEVER, + of, + timer, + throwError, + Observable, +} from "rxjs" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { isSessionEnded } from "../../modules/OAuth2/session" + +const sessionEnded$ = new Observable((subscriber) => + authSessionState.onEnd(() => subscriber.next()), +) const MAX_RETRIES = 5 const RETRY_BASE_DELAY_MS = 1000 @@ -50,54 +66,33 @@ import { StoreShape, TelemetryAction, TelemetryAT, - TelemetryConfigShape, } from "../../types" import { fromFetch } from "../../utils" import * as QuestDB from "../../utils/questdb" -import { getValue } from "../../utils/localStorage" -import { StoreKey } from "../../utils/localStorage/types" -import { AuthPayload } from "../../modules/OAuth2/types" +import { questClient as quest } from "../../utils/questdb/queryClient" import { sendServerInfoTelemetry, getTelemetryTimestamp, } from "../../utils/telemetry" -import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" -const quest = new QuestDB.Client() +import { + queryTelemetryConfig, + TELEMETRY_QUERY_OPTIONS, +} from "../../utils/telemetryQueries" export const getServerInfo: Epic = ( action$, ) => action$.pipe( ofType(TelemetryAT.START), - switchMap(() => { - // Set an authorization header for the QuestDB client instance within the telemetry epic - const authPayload = ssoAuthState.getAuthPayload() - const token = authPayload ?? ({} as AuthPayload) - if (token.access_token) { - quest.setCommonHeaders({ - Authorization: `Bearer ${token.groups_encoded_in_token ? token.id_token : token.access_token}`, - }) - } else { - const restToken = getValue(StoreKey.REST_TOKEN) - if (restToken) { - quest.setCommonHeaders({ - Authorization: `Bearer ${restToken}`, - }) - } else { - const basicAuth = getValue(StoreKey.BASIC_AUTH_HEADER) - if (basicAuth) { - quest.setCommonHeaders({ - Authorization: basicAuth, - }) - } - } - } - return from( - quest.query(`${TelemetryTable.CONFIG} limit -1`), - ) - }), + switchMap(() => + from(queryTelemetryConfig(quest)).pipe( + // Keep the root epic alive so a later login/START can retry. + catchError(() => EMPTY), + takeUntil(sessionEnded$), + ), + ), switchMap((response) => { if (response.type === QuestDB.Type.DQL) { return of(actions.telemetry.setConfig(response.data[0])) @@ -120,7 +115,7 @@ export const getLatestTelemetryTimestamp: Epic< if (serverInfo) { void sendServerInfoTelemetry(serverInfo) } - return getTelemetryTimestamp(serverInfo) + return getTelemetryTimestamp(serverInfo).pipe(takeUntil(sessionEnded$)) }), map((response) => { // Start the loop even if fetching remote config fails. @@ -182,17 +177,32 @@ export const startTelemetry: Epic = ( ` .replace(/\s+/g, " ") .trim(), + TELEMETRY_QUERY_OPTIONS, ), - ).pipe(map((result) => ({ type: "data" as const, result, remoteConfig }))) + ).pipe( + map((result) => ({ type: "data" as const, result, remoteConfig })), + // Keep the root listener alive for a later login, but stop the old + // session's loop on authentication failure. Transient failures retry. + catchError((error: unknown) => + error && + typeof error === "object" && + (("status" in error && error.status === 401) || isSessionEnded(error)) + ? of({ type: "stop" as const }) + : of({ type: "skip" as const, remoteConfig }), + ), + takeUntil(sessionEnded$), + ) }), withLatestFrom(state$), switchMap(([payload, state]) => { + if (payload.type === "stop") return EMPTY const config = selectors.telemetry.getConfig(state) if (payload.type === "skip") { // No lastUpdated, schedule next check return of(null).pipe( delay(TELEMETRY_INTERVAL_MS), + takeUntil(sessionEnded$), map(() => actions.telemetry.setRemoteConfig(payload.remoteConfig ?? {}), ), @@ -252,6 +262,7 @@ export const startTelemetry: Epic = ( }), catchError(() => of(remoteConfig)), delay(TELEMETRY_INTERVAL_MS), + takeUntil(sessionEnded$), map((cfg) => actions.telemetry.setRemoteConfig(cfg ?? {})), ) } @@ -259,6 +270,7 @@ export const startTelemetry: Epic = ( // No data to send, but still schedule next check in 1 hour return of(null).pipe( delay(TELEMETRY_INTERVAL_MS), + takeUntil(sessionEnded$), map(() => actions.telemetry.setRemoteConfig(remoteConfig ?? {})), ) }), diff --git a/src/store/notebook.ts b/src/store/notebook.ts index 2af1c3443..d99c04f46 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -1,5 +1,9 @@ import type { editor } from "monaco-editor" -import type { ColumnDefinition, Timings } from "../utils/questdb/types" +import type { + ColumnDefinition, + Timings, + SessionFailure, +} from "../utils/questdb/types" import type { RunStatus } from "../utils/ai/runStatus" import type { ChartConfig } from "../scenes/Editor/Notebook/CellChart/chartTypes" import type { HighlightConfig } from "../components/ResultGrid/highlight/types" @@ -95,7 +99,7 @@ export type DdlDmlQueryResult = { fetchedAt?: number } -export type ErrorQueryResult = { +export type ErrorQueryResult = SessionFailure & { type: "error" query: string error: string diff --git a/src/utils/executeSingleRaw.test.ts b/src/utils/executeSingleRaw.test.ts index ee5e98fa3..001e97f76 100644 --- a/src/utils/executeSingleRaw.test.ts +++ b/src/utils/executeSingleRaw.test.ts @@ -18,6 +18,22 @@ const questReturningOneRow = () => }) as unknown as Client describe("executeSingleRaw", () => { + it("treats a resolved ErrorResult as failure rather than a successful write", async () => { + const error = { + type: QuestDB.Type.ERROR, + error: "not sent", + position: -1, + query: "insert into t values (1)", + requestNotSent: true, + } + const quest = { + queryRaw: vi.fn(() => ({ promise: Promise.resolve(error), queryId: 1 })), + abort: vi.fn(), + } as unknown as Client + expect(await executeSingleRaw(quest, error.query, undefined)).toMatchObject( + { type: "error", error: "not sent", requestNotSent: true }, + ) + }) it("records the effective query, which differs per variable value while the query stays the same", async () => { // Given one statement that reads a notebook variable const sql = "select price from trades where symbol = @sym" diff --git a/src/utils/executeSingleRaw.ts b/src/utils/executeSingleRaw.ts index 0f427f875..085a2f7e5 100644 --- a/src/utils/executeSingleRaw.ts +++ b/src/utils/executeSingleRaw.ts @@ -3,10 +3,14 @@ import type { Client } from "./questdb/client" import type { ColumnDefinition, Timings } from "./questdb/types" import { expandGlobals } from "../scenes/Editor/Notebook/declareUtils" import type { NotebookVariable } from "../store/notebook" +import { + sessionFailureOf, + type SessionFailure, +} from "../modules/OAuth2/session" export const RESULT_DISPLAY_LIMIT = 50_000 -export type QueryExecResult = { +export type QueryExecResult = SessionFailure & { type: "dql" | "ddl" | "dml" | "error" query: string effectiveQuery?: string @@ -29,6 +33,7 @@ export const executeSingleRaw = async ( globals: NotebookVariable[] | undefined, signal?: AbortSignal, limit: number = RESULT_DISPLAY_LIMIT, + options: { background?: boolean } = {}, ): Promise => { let cleanup: (() => void) | undefined try { @@ -46,13 +51,18 @@ export const executeSingleRaw = async ( const { promise, queryId } = quest.queryRaw(expanded, { limit: `0,${limit}`, cancellable: true, + ...(options.background ? { background: true } : {}), }) if (signal) { const onAbort = () => quest.abort(queryId) signal.addEventListener("abort", onAbort, { once: true }) cleanup = () => signal.removeEventListener("abort", onAbort) } - const result = await promise + // Normalize adapters/test doubles that resolve errors to the client's + // usual rejection contract, so one catch formats every failure. + const result = await promise.then((result) => + result.type === QuestDB.Type.ERROR ? Promise.reject(result) : result, + ) if (result.type === QuestDB.Type.DQL) { return { @@ -81,17 +91,6 @@ export const executeSingleRaw = async ( } } - if (result.type === QuestDB.Type.ERROR) { - return { - type: "error", - query: sql, - columns: [], - dataset: [], - count: 0, - error: result.error, - } - } - return { type: result.type === QuestDB.Type.DDL ? "ddl" : "dml", query: sql, @@ -109,6 +108,7 @@ export const executeSingleRaw = async ( count: 0, error: (err?.error as string) ?? (err?.message as string) ?? "Unknown error", + ...sessionFailureOf(e), } } finally { cleanup?.() diff --git a/src/utils/localStorage/types.ts b/src/utils/localStorage/types.ts index 132ffe5f8..fc270f16f 100644 --- a/src/utils/localStorage/types.ts +++ b/src/utils/localStorage/types.ts @@ -35,6 +35,8 @@ export enum StoreKey { EXAMPLE_QUERIES_VISITED = "editor.exampleQueriesVisited", EDITOR_SPLITTER_BASIS = "splitter.editor.basis", RESULTS_SPLITTER_BASIS = "splitter.results.basis", + // Stored in sessionStorage: reloading one tab must not switch its login method. + LOGIN_METHOD = "auth.login.method", REST_TOKEN = "rest.token", BASIC_AUTH_HEADER = "basic.auth.header", AUTO_REFRESH_TABLES = "auto.refresh.tables", diff --git a/src/utils/questdb/cancellation.ts b/src/utils/questdb/cancellation.ts new file mode 100644 index 000000000..1a48aea18 --- /dev/null +++ b/src/utils/questdb/cancellation.ts @@ -0,0 +1,8 @@ +export const CANCELLED_BY_USER = "Cancelled by user" + +export const isCancelledByUser = (error: unknown): boolean => + error === CANCELLED_BY_USER || + (typeof error === "object" && + error !== null && + "error" in error && + error.error === CANCELLED_BY_USER) diff --git a/src/utils/questdb/client.auth.test.ts b/src/utils/questdb/client.auth.test.ts new file mode 100644 index 000000000..9e909bb6a --- /dev/null +++ b/src/utils/questdb/client.auth.test.ts @@ -0,0 +1,1065 @@ +import "../../test/stubBrowserGlobals" +import { afterEach, describe, expect, it, vi } from "vitest" +import { Client } from "./client" +import type { ErrorResult } from "./types" +import { + REFRESH_RETRY_DELAY_MS, + REFRESH_REQUEST_TIMEOUT_MS, + tokenRefresh, +} from "./tokenRefresh" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" +import { getTokenExpirationDate } from "../../modules/OAuth2/utils" +import { Base64 } from "js-base64" +import type { AuthPayload } from "../../modules/OAuth2/types" +import { + isUnverifiableExecError, + singleResultFromExec, + summarizeCellResults, +} from "../../scenes/Editor/Notebook/notebookUtils" +import { executeSingleRaw } from "../executeSingleRaw" +import { mapQueryRawToResult } from "../tools/runQuery" + +const token = (overrides: Partial = {}): AuthPayload => ({ + access_token: "old", + refresh_token: "refresh", + token_type: "Bearer", + expires_in: 300, + expires_at: new Date(Date.now() + 300_000).toISOString(), + ...overrides, +}) +const ok = () => new Response(JSON.stringify({ dml: true }), { status: 200 }) +const unauthorized = () => new Response("", { status: 401 }) + +const listen = (type: EventType) => { + const listener = vi.fn() + eventBus.subscribe(type, listener) + return { listener, cleanup: () => eventBus.unsubscribe(type, listener) } +} + +afterEach(() => { + authSessionState.endSession() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe("SSO refresh safety", () => { + it.each(["SSO", "password"])( + "records a forbidden statement as unexecuted before %s logout", + async (login) => { + if (login === "SSO") authSessionState.setAuthPayload(token()) + else authSessionState.markNonSSOAuthenticated() + const client = new Client() + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ error: "Access denied to protocol [HTTP]" }), + { status: 403 }, + ), + ), + ), + ) + const forbidden = vi.fn(() => { + expect(client.hasSentRequests()).toBe(false) + authSessionState.endSession() + }) + eventBus.subscribe(EventType.MSG_CONNECTION_FORBIDDEN, forbidden) + try { + await expect( + client.queryRaw("insert into trades values (1)"), + ).rejects.toMatchObject({ + sessionEnded: true, + requestNotSent: true, + }) + expect(forbidden).toHaveBeenCalledOnce() + } finally { + eventBus.unsubscribe(EventType.MSG_CONNECTION_FORBIDDEN, forbidden) + } + }, + ) + + it("retries a forced 401 refresh after backoff even outside the expiry window", async () => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload(token()) + const client = new Client() + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + Promise.resolve( + (init?.headers as Record).Authorization === + "Bearer recovered" + ? ok() + : unauthorized(), + ), + ) + vi.stubGlobal("fetch", fetchMock) + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("IdP unavailable")) + .mockImplementationOnce(() => { + const payload = token({ access_token: "recovered" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + client.refreshTokenMethod = refresh + await expect(client.queryRaw("select 1")).rejects.toMatchObject({ + requestNotSent: true, + }) + await expect(client.queryRaw("select 1")).rejects.toMatchObject({ + requestNotSent: true, + }) + expect(refresh).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(REFRESH_RETRY_DELAY_MS) + await expect(client.queryRaw("select 1")).resolves.toMatchObject({ + type: "dml", + }) + expect(refresh).toHaveBeenCalledTimes(2) + expect(authSessionState.hasRefreshFailed()).toBe(false) + }) + + it("retains a rotated token arriving after the query wait when the bearer was already expired", async () => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() - 1).toISOString() }), + ) + const client = new Client() + const fetchMock = vi.fn().mockImplementation(ok) + vi.stubGlobal("fetch", fetchMock) + let requestSignal!: AbortSignal + client.refreshTokenMethod = (_softDeadline, signal) => { + requestSignal = signal + return new Promise((resolve) => + setTimeout(() => { + const payload = token({ access_token: "rotated" }) + if (!signal.aborted) authSessionState.setAuthPayload(payload) + resolve(payload) + }, 15_000), + ) + } + const pending = client.queryRaw("select 1").catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(10_000) + expect(await pending).toMatchObject({ requestNotSent: true }) + expect(requestSignal.aborted).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5_000) + await expect(client.queryRaw("select 1")).resolves.toMatchObject({ + type: "dml", + }) + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer rotated" } }), + ) + }) + + it.each([0, 21_000])( + "preserves the session after a settled transient refresh failure and a SQL 401 delayed %s ms", + async (delay) => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + const original = token({ + expires_at: new Date(Date.now() + 20_000).toISOString(), + }) + authSessionState.setAuthPayload(original) + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => resolve(unauthorized()), delay) + }), + ) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + const refresh = vi + .fn() + .mockRejectedValueOnce(new TypeError("offline")) + .mockImplementationOnce(() => { + const payload = token({ access_token: "recovered" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + client.refreshTokenMethod = refresh + const unauthorizedEvent = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + const connectionError = listen(EventType.MSG_CONNECTION_ERROR) + const expectRenewalFailure = async () => { + const pending = client + .queryRaw("INSERT INTO trades VALUES (1)") + .catch((error: unknown) => error as ErrorResult) + await vi.advanceTimersByTimeAsync(delay) + const failure = await pending + expect(failure).toMatchObject({ + type: "error", + requestNotSent: true, + }) + if (failure.type !== "error") + throw new Error("Expected a renewal error") + expect(failure.error).toContain("the request was not sent") + expect(failure).not.toHaveProperty("sessionEnded") + expect(isUnverifiableExecError(failure)).toBe(false) + } + try { + await expectRenewalFailure() + expect(tokenRefresh.hardDeadline).toBeUndefined() + expect(authSessionState.getAuthPayload()).toBe(original) + expect(authSessionState.isAuthenticated()).toBe(true) + expect(authSessionState.hasRefreshFailed()).toBe(true) + expect(refresh).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledOnce() + + // A later query during backoff has no pre-fetch refresh to wait for. + if (delay === 0) { + await expectRenewalFailure() + expect(refresh).toHaveBeenCalledOnce() + } + expect(unauthorizedEvent.listener).not.toHaveBeenCalled() + expect(connectionError.listener).not.toHaveBeenCalled() + + fetchMock.mockImplementation(() => Promise.resolve(ok())) + await vi.advanceTimersByTimeAsync(REFRESH_RETRY_DELAY_MS) + await expect(client.queryRaw("SELECT 1")).resolves.toMatchObject({ + type: "dml", + }) + expect(refresh).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bearer recovered" }, + }), + ) + expect(authSessionState.hasRefreshFailed()).toBe(false) + expect(unauthorizedEvent.listener).not.toHaveBeenCalled() + } finally { + unauthorizedEvent.cleanup() + connectionError.cleanup() + } + }, + ) + + it("reports expired-token backoff as not-sent, never as a database outage or uncertain write", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() - 1).toISOString() }), + ) + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.refreshTokenMethod = vi + .fn() + .mockRejectedValue(new Error("IdP unavailable")) + const connection = listen(EventType.MSG_CONNECTION_ERROR) + const refresh = listen(EventType.MSG_AUTH_REFRESH_FAILED) + try { + for (let i = 0; i < 2; i++) { + const failure = await client + .queryRaw("insert into trades values (1)") + .catch((error: unknown) => error as ErrorResult) + expect(failure).toMatchObject({ + type: "error", + requestNotSent: true, + }) + if (failure.type !== "error") + throw new Error("Expected a renewal error") + expect(failure.error).toContain("request was not sent") + expect(isUnverifiableExecError(failure)).toBe(false) + } + expect(fetchMock).not.toHaveBeenCalled() + expect(connection.listener).not.toHaveBeenCalled() + expect(refresh.listener).toHaveBeenCalledOnce() + } finally { + connection.cleanup() + refresh.cleanup() + } + }) + + it.each([20_000, 300_000])( + "waits through the hard deadline after a 401 (token lifetime %s)", + async (lifetime) => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() + lifetime).toISOString() }), + ) + const client = new Client() + vi.stubGlobal( + "fetch", + vi.fn().mockImplementationOnce(unauthorized).mockImplementation(ok), + ) + client.refreshTokenMethod = () => + new Promise((resolve) => + setTimeout(() => { + const payload = token({ access_token: "rotated" }) + authSessionState.setAuthPayload(payload) + resolve(payload) + }, 15_000), + ) + const unauthorizedEvent = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + try { + let settled = false + const query = client + .queryRaw("insert into trades values (1)") + .then((result) => { + settled = true + return result + }) + await vi.advanceTimersByTimeAsync(10_000) + expect(settled).toBe(false) + expect(authSessionState.isAuthenticated()).toBe(true) + expect(unauthorizedEvent.listener).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5_000) + expect((await query).type).toBe("dml") + expect(unauthorizedEvent.listener).not.toHaveBeenCalled() + } finally { + unauthorizedEvent.cleanup() + } + }, + ) + + it("bounds successful refreshes when the browser is ahead by an ID-token lifetime", async () => { + vi.useFakeTimers() + const providerNow = Date.UTC(2026, 0, 1) + vi.setSystemTime(providerNow + 300_000) + const issue = () => { + const payload = token({ + groups_encoded_in_token: true, + id_token: `header.${Base64.encodeURI( + JSON.stringify({ + iat: providerNow / 1000, + exp: providerNow / 1000 + 300, + }), + )}.signature`, + }) + const expiresAt = getTokenExpirationDate(payload) + if (!expiresAt) throw new Error("The test ID token must have an exp") + return { ...payload, expires_at: expiresAt.toISOString() } + } + authSessionState.setAuthPayload(issue()) + const fetchMock = vi.fn().mockImplementation(ok) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + const refresh = vi.fn(() => { + const payload = issue() + authSessionState.setAuthPayload(payload) + // QuestProvider's sessionData effect schedules this after each success. + void client.queryRaw("SELECT 1") + return Promise.resolve(payload) + }) + client.refreshTokenMethod = refresh + + await client.queryRaw("SELECT 1") + for (let i = 0; i < 25; i++) await client.queryRaw("SELECT 1") + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(29_999) + await client.queryRaw("SELECT 1") + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await client.queryRaw("SELECT 1") + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it("floors refresh frequency when clock skew leaves two seconds on every token", async () => { + vi.useFakeTimers() + const issue = () => + token({ expires_at: new Date(Date.now() + 2_000).toISOString() }) + authSessionState.setAuthPayload(issue()) + vi.stubGlobal("fetch", vi.fn().mockImplementation(ok)) + const client = new Client() + client.refreshTokenMethod = vi.fn(() => { + const payload = issue() + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + await client.queryRaw("SELECT 1") + for (let i = 0; i < 4; i++) { + await vi.advanceTimersByTimeAsync(1_000) + await client.queryRaw("SELECT 1") + } + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1_000) + await client.queryRaw("SELECT 1") + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(2) + }) + + it("does not throttle a short-lived token past its expiry", async () => { + vi.useFakeTimers() + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() + 20_000).toISOString() }), + ) + vi.stubGlobal("fetch", vi.fn().mockImplementation(ok)) + const client = new Client() + client.refreshTokenMethod = vi.fn(() => { + const payload = token({ + expires_at: new Date(Date.now() + 20_000).toISOString(), + }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + await client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(10_000) + await client.queryRaw("SELECT 2") + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(2) + }) + + it("refreshes and retries a current-bearer 401 once when the browser clock is behind", async () => { + authSessionState.setAuthPayload(token()) // The browser thinks expiry is far away. + const fetchMock = vi + .fn() + .mockImplementationOnce(unauthorized) + .mockImplementation(ok) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer old" }) + client.refreshTokenMethod = vi.fn(() => { + const payload = token({ access_token: "new" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + const { listener, cleanup } = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + try { + await client.queryRaw("INSERT INTO trades VALUES (1)") + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer new" } }), + ) + expect(listener).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + + it("publishes one unauthorized event if the retried token is also rejected", async () => { + authSessionState.setAuthPayload(token()) + const fetchMock = vi.fn().mockImplementation(unauthorized) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer old" }) + client.refreshTokenMethod = vi.fn(() => { + const payload = token({ access_token: "new" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + const { listener, cleanup } = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + try { + await expect(client.queryRaw("SELECT 1")).rejects.toMatchObject({ + status: 401, + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledTimes(1) + } finally { + cleanup() + } + }) + + it("never refreshes a token of unknown expiry, and reports its 401", async () => { + // Given a provider that sent no lifetime, although it sent a refresh token + const { expires_at: _, ...unknownExpiry } = token() + authSessionState.setAuthPayload(unknownExpiry) + const fetchMock = vi + .fn() + .mockImplementationOnce(ok) + .mockImplementation(unauthorized) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer old" }) + const refresh = vi.fn() + client.refreshTokenMethod = refresh + const { listener, cleanup } = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + try { + await client.queryRaw("SELECT 1") + + // When the server rejects the expired token + await expect(client.queryRaw("SELECT 2")).rejects.toMatchObject({ + status: 401, + }) + + // Then nothing is refreshed or retried: the user signs in again. + expect(refresh).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenCalledTimes(1) + } finally { + cleanup() + } + }) + + it.each([ + { lifetime: 20_000, requests: 0 }, + { lifetime: 300_000, requests: 1 }, + ])( + "keeps a write definitively not-sent when refresh ends the session after $requests SQL requests", + async ({ lifetime, requests }) => { + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() + lifetime).toISOString() }), + ) + const fetchMock = vi.fn().mockImplementation(unauthorized) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer old" }) + client.refreshTokenMethod = () => { + authSessionState.endSession() + return Promise.resolve(undefined) + } + const { listener, cleanup } = listen(EventType.MSG_CONNECTION_ERROR) + try { + const failure = await mapQueryRawToResult( + client, + "INSERT INTO trades VALUES (1)", + 100, + ) + expect(failure).toMatchObject({ + type: "error", + error: "Your session has ended. Please sign in again.", + sessionEnded: true, + requestNotSent: true, + }) + expect(isUnverifiableExecError(failure)).toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(requests) + expect(listener).not.toHaveBeenCalled() + } finally { + cleanup() + } + }, + ) + + it.each([ + { lifetime: 20_000, requests: 0 }, + { lifetime: 300_000, requests: 1 }, + ])( + "keeps a cancelled write definitively not-sent while refreshing after $requests SQL requests", + async ({ lifetime, requests }) => { + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() + lifetime).toISOString() }), + ) + const fetchMock = vi.fn().mockImplementation(unauthorized) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + let finishRefresh!: (payload: AuthPayload) => void + let requestSignal: AbortSignal | undefined + let startRefresh!: () => void + const started = new Promise((resolve) => { + startRefresh = resolve + }) + client.refreshTokenMethod = vi.fn((_deadline, signal) => { + requestSignal = signal + startRefresh() + return new Promise((resolve) => { + finishRefresh = resolve + }) + }) + const controller = new AbortController() + const sql = "INSERT INTO trades VALUES (1)" + const pending = executeSingleRaw( + client, + sql, + undefined, + controller.signal, + ) + await started + const completion = tokenRefresh.hardDeadline + controller.abort() + const result = await pending + expect(result).toMatchObject({ + type: "error", + error: "Cancelled by user", + requestNotSent: true, + }) + expect(isUnverifiableExecError(result)).toBe(false) + const summary = summarizeCellResults({ + id: "cancelled", + position: 0, + value: sql, + result: { + results: [singleResultFromExec(result, sql)], + activeResultIndex: 0, + timestamp: Date.now(), + }, + }) + expect(summary).not.toHaveProperty("unverified") + expect(summary).not.toHaveProperty("note") + expect(fetchMock).toHaveBeenCalledTimes(requests) + expect(requestSignal?.aborted).toBe(false) + + const payload = token({ + access_token: "renewed", + refresh_token: "rotated", + }) + authSessionState.setAuthPayload(payload) + finishRefresh(payload) + await completion + expect(fetchMock).toHaveBeenCalledTimes(requests) + expect(client.refreshTokenMethod).toHaveBeenCalledOnce() + }, + ) + + it.each([false, true])( + "keeps cancellation of an unconfirmed SQL fetch uncertain (retry: %s)", + async (retry) => { + authSessionState.setAuthPayload(token()) + let startRequest!: () => void + const started = new Promise((resolve) => { + startRequest = resolve + }) + const pendingFetch = (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal!.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + startRequest() + }) + const fetchMock = vi.fn(pendingFetch) + if (retry) + fetchMock.mockImplementationOnce(() => Promise.resolve(unauthorized())) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.refreshTokenMethod = () => { + const payload = token({ access_token: "renewed" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + } + const controller = new AbortController() + const pending = executeSingleRaw( + client, + "INSERT INTO trades VALUES (1)", + undefined, + controller.signal, + ) + await started + controller.abort() + const result = await pending + expect(result).toMatchObject({ + type: "error", + error: "Cancelled by user", + }) + expect(result).not.toHaveProperty("requestNotSent") + expect(isUnverifiableExecError(result)).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(retry ? 2 : 1) + }, + ) + + it("keeps an in-flight write uncertain when its session ends", async () => { + authSessionState.setAuthPayload(token()) + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise((resolve) => (finish = resolve))), + ) + const pending = mapQueryRawToResult( + new Client(), + "INSERT INTO trades VALUES (1)", + 100, + ) + authSessionState.endSession() + finish(ok()) + const failure = await pending + expect(failure).toMatchObject({ type: "error", sessionEnded: true }) + expect(failure).not.toHaveProperty("requestNotSent") + expect(isUnverifiableExecError(failure)).toBe(true) + expect(fetch).toHaveBeenCalledOnce() + }) + + it("does not let an old in-flight refresh block or overwrite a new login", async () => { + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() + 20_000).toISOString() }), + ) + const fetchMock = vi.fn().mockImplementation(ok) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + let resolveOld!: (value: AuthPayload) => void + client.refreshTokenMethod = () => + new Promise((resolve) => { + resolveOld = resolve + }) + const parked = client.queryRaw("INSERT INTO trades VALUES (1)") + const cancelled = expect(parked).rejects.toMatchObject({ + error: "Your session has ended. Please sign in again.", + }) + await Promise.resolve() + authSessionState.endSession() + authSessionState.setAuthPayload( + token({ + access_token: "new-login", + expires_at: new Date(Date.now() + 20_000).toISOString(), + }), + ) + client.refreshTokenMethod = () => { + const payload = token({ access_token: "new-refreshed" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + } + await client.queryRaw("SELECT 1") + resolveOld(token({ access_token: "obsolete" })) + await cancelled + await client.queryRaw("SELECT 2") + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bearer new-refreshed" }, + }), + ) + }) + + it("caps failure backoff at expiry so a recovered provider gets another chance", async () => { + vi.useFakeTimers() + const expiry = Date.now() + 30_000 + authSessionState.setAuthPayload( + token({ expires_at: new Date(expiry).toISOString() }), + ) + vi.stubGlobal("fetch", vi.fn().mockImplementation(ok)) + vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + const refresh = vi.fn().mockRejectedValue(new Error("offline")) + client.refreshTokenMethod = refresh + await vi.advanceTimersByTimeAsync(1_000) + await client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(5_000) + await client.queryRaw("SELECT 2") + await vi.advanceTimersByTimeAsync(10_000) + await client.queryRaw("SELECT 3") + expect(refresh).toHaveBeenCalledTimes(3) + refresh.mockImplementationOnce(() => { + const payload = token({ access_token: "recovered" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + await vi.advanceTimersByTimeAsync(14_000) + await client.queryRaw("SELECT 4") + expect(refresh).toHaveBeenCalledTimes(4) + expect(authSessionState.hasRefreshFailed()).toBe(false) + }) +}) + +describe("transient post-expiry refresh recovery", () => { + it("does not send an expired bearer during backoff and recovers on retry", async () => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() - 1_000).toISOString() }), + ) + const fetchMock = vi + .fn<[string, RequestInit], Promise>() + .mockImplementation(() => Promise.resolve(ok())) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + const refresh = vi + .fn() + .mockRejectedValueOnce(new TypeError("offline")) + .mockImplementationOnce(() => { + const payload = token({ access_token: "recovered" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + client.refreshTokenMethod = refresh + const { listener, cleanup } = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + try { + await expect( + client.queryRaw("INSERT INTO trades VALUES (1)"), + ).rejects.toMatchObject({ type: "error" }) + await expect(client.queryRaw("SELECT 1")).rejects.toMatchObject({ + type: "error", + }) + expect(fetchMock).not.toHaveBeenCalled() + expect(listener).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(REFRESH_RETRY_DELAY_MS) + await client.queryRaw("SELECT 1") + expect(refresh).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.lastCall?.[1]?.headers).toEqual({ + Authorization: "Bearer recovered", + }) + } finally { + cleanup() + } + }) + + it("keeps the session during repeated transient post-expiry failures", async () => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() - 1_000).toISOString() }), + ) + const fetchMock = vi.fn().mockImplementation(ok) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.refreshTokenMethod = vi.fn().mockRejectedValue(new Error("offline")) + const { listener, cleanup } = listen(EventType.MSG_CONNECTION_UNAUTHORIZED) + try { + await expect(client.queryRaw("SELECT 1")).rejects.toMatchObject({ + type: "error", + }) + await vi.advanceTimersByTimeAsync(5_000) + await expect(client.queryRaw("SELECT 2")).rejects.toMatchObject({ + type: "error", + }) + expect(listener).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(10_000) + await expect(client.queryRaw("SELECT 3")).rejects.toMatchObject({ + type: "error", + }) + expect(listener).not.toHaveBeenCalled() + expect(authSessionState.isAuthenticated()).toBe(true) + expect(fetchMock).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + + it.each([20_000, 300_000])( + "frees and aborts a hung slot at the fixed hard deadline (%s ms lifetime)", + async (lifetime) => { + vi.useFakeTimers() + vi.spyOn(console, "error").mockImplementation(() => {}) + authSessionState.setAuthPayload( + token({ expires_at: new Date(Date.now() + lifetime).toISOString() }), + ) + vi.stubGlobal( + "fetch", + vi.fn().mockImplementationOnce(unauthorized).mockImplementation(ok), + ) + const client = new Client() + const requests: AbortSignal[] = [] + client.refreshTokenMethod = vi.fn( + (_deadline: AbortSignal, network: AbortSignal) => { + requests.push(network) + return new Promise(() => {}) + }, + ) + const first = client.queryRaw("SELECT 1").catch(() => {}) + // If not in the refresh window, the server-side 401 starts recovery. + await vi.advanceTimersByTimeAsync(10_000) + expect(requests).toHaveLength(1) + expect(requests[0].aborted).toBe(false) + const duration = REFRESH_REQUEST_TIMEOUT_MS + await vi.advanceTimersByTimeAsync(duration - 10_000) + expect(requests[0].aborted).toBe(true) + await first + client.refreshTokenMethod = vi.fn(() => { + const payload = token({ access_token: "recovered" }) + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + // Move into the refresh window for the long-lifetime case. + if (lifetime > duration) + await vi.advanceTimersByTimeAsync(lifetime - duration) + await client.queryRaw("SELECT 2") + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(1) + }, + ) +}) + +describe("query completion session isolation", () => { + const resultBody = JSON.stringify({ + columns: [{ name: "table_name", type: "STRING" }], + dataset: [["private_old_session_table"]], + count: 1, + }) + + describe.each(["response", "body"] as const)("delayed %s", (phase) => { + it.each(phase === "response" ? [200, 400, 401, 403, 502] : [200, 400, 403])( + "discards HTTP %s after refresh logout and password re-login", + async (status) => { + vi.useFakeTimers() + authSessionState.setAuthPayload( + token({ + expires_at: new Date(Date.now() + 60_000).toISOString(), + }), + ) + const body = + status === 200 + ? resultBody + : JSON.stringify({ error: "Access denied to protocol [HTTP]" }) + let finish!: () => void + const held = new Promise((resolve) => { + if (phase === "response") { + finish = () => resolve(new Response(body, { status })) + } else { + const text = new Promise((resolveBody) => { + finish = () => resolveBody(body) + }) + resolve({ + ok: status === 200, + status, + text: () => text, + } as Response) + } + }) + vi.stubGlobal("fetch", vi.fn().mockReturnValue(held)) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer old" }) + const commitCatalog = vi.fn() + const publish = vi.spyOn(eventBus, "publish") + const oldQuery = client.showTables().then(commitCatalog) + const rejected = expect(oldQuery).rejects.toMatchObject({ + sessionEnded: true, + }) + await vi.advanceTimersByTimeAsync(31_000) + client.refreshTokenMethod = () => { + authSessionState.endSession() + return Promise.resolve(undefined) + } + await expect(client.queryRaw("SELECT 1")).rejects.toMatchObject({ + sessionEnded: true, + }) + authSessionState.markNonSSOAuthenticated() + client.setCommonHeaders({ Authorization: "Bearer new-user" }) + publish.mockClear() + + finish() + await rejected + + expect(commitCatalog).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(authSessionState.isAuthenticated()).toBe(true) + }, + ) + }) + + it.each(["network", "body"] as const)( + "suppresses an old session's delayed %s failure", + async (phase) => { + authSessionState.setAuthPayload(token()) + let fail!: (error: Error) => void + const pending = new Promise((_, reject) => { + fail = reject + }) + const readBody = vi.fn(() => pending) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockReturnValue( + phase === "network" + ? pending + : Promise.resolve({ ok: true, status: 200, text: readBody }), + ), + ) + const publish = vi.spyOn(eventBus, "publish") + const client = new Client() + const query = client.queryRaw("SELECT 1") + const rejected = expect(query).rejects.toMatchObject({ + sessionEnded: true, + }) + if (phase === "body") + await vi.waitFor(() => expect(readBody).toHaveBeenCalled()) + authSessionState.endSession() + authSessionState.markNonSSOAuthenticated() + fail(new Error("Old request failed")) + await rejected + expect(publish).not.toHaveBeenCalled() + }, + ) + + it.each([200, 403, 502])( + "discards a password session's delayed HTTP %s result after re-login", + async (status) => { + authSessionState.markNonSSOAuthenticated() + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + const publish = vi.spyOn(eventBus, "publish") + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer user-a" }) + const query = client.queryRaw("SELECT 1") + const rejected = expect(query).rejects.toMatchObject({ + sessionEnded: true, + }) + authSessionState.endSession() + authSessionState.markNonSSOAuthenticated() + client.setCommonHeaders({ Authorization: "Bearer user-b" }) + finish( + new Response( + status === 200 ? resultBody : "Access denied to protocol [HTTP]", + { status }, + ), + ) + await rejected + expect(publish).not.toHaveBeenCalled() + expect(authSessionState.isAuthenticated()).toBe(true) + }, + ) + + it("allows a delayed result after token refresh in the same session", async () => { + authSessionState.setAuthPayload(token()) + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + const client = new Client() + const query = client.showTables() + authSessionState.setAuthPayload(token({ access_token: "refreshed" })) + finish(new Response(resultBody)) + await expect(query).resolves.toMatchObject({ + type: "dql", + data: [{ table_name: "private_old_session_table" }], + }) + }) +}) + +describe("REST/basic 401 comparison", () => { + it.each(["Bearer rest-token", "Basic dXNlcjpwYXNz"])( + "publishes a 401 for the current %s header", + async (authorization) => { + vi.stubGlobal("fetch", vi.fn().mockImplementation(unauthorized)) + const client = new Client() + client.setCommonHeaders({ Authorization: authorization }) + const { listener, cleanup } = listen( + EventType.MSG_CONNECTION_UNAUTHORIZED, + ) + try { + await expect(client.queryRaw("SELECT 1")).rejects.toMatchObject({ + status: 401, + }) + expect(listener).toHaveBeenCalledTimes(1) + } finally { + cleanup() + } + }, + ) + + it.each(["Bearer rest-token", "Basic dXNlcjpwYXNz"])( + "ignores a stale 401 after replacing %s", + async (authorization) => { + let resolve!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((done) => { + resolve = done + }), + ), + ) + const client = new Client() + client.setCommonHeaders({ Authorization: authorization }) + const { listener, cleanup } = listen( + EventType.MSG_CONNECTION_UNAUTHORIZED, + ) + try { + const query = client.queryRaw("SELECT 1") + client.setCommonHeaders({ Authorization: "Bearer replacement" }) + resolve(unauthorized()) + await expect(query).rejects.toMatchObject({ status: 401 }) + expect(listener).not.toHaveBeenCalled() + } finally { + cleanup() + } + }, + ) +}) diff --git a/src/utils/questdb/client.backgroundAuth.test.ts b/src/utils/questdb/client.backgroundAuth.test.ts new file mode 100644 index 000000000..0e28fffac --- /dev/null +++ b/src/utils/questdb/client.backgroundAuth.test.ts @@ -0,0 +1,74 @@ +import "../../test/stubBrowserGlobals" +import { afterEach, describe, expect, it, vi } from "vitest" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" +import { Client } from "./client" + +const install = (remaining: number) => + authSessionState.setAuthPayload({ + access_token: "idle-user", + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + remaining).toISOString(), + }) +afterEach(() => { + authSessionState.endSession() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe("non-renewing background SQL", () => { + it.each([-1, 10_000])( + "does not send or renew a bearer with %s ms remaining", + async (remaining) => { + install(remaining) + const client = new Client() + client.refreshTokenMethod = vi.fn() + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + await expect( + client.queryRaw("telemetry", { refreshAuth: false }), + ).rejects.toMatchObject({ requestNotSent: true }) + expect(client.refreshTokenMethod).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(authSessionState.isAuthenticated()).toBe(true) + }, + ) + + it("neither refreshes nor logs out after a server-side 401 (clock skew)", async () => { + install(300_000) + const session = authSessionState.capture() + const client = new Client() + client.refreshTokenMethod = vi.fn() + const publish = vi.spyOn(eventBus, "publish") + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("", { status: 401 }))), + ) + await expect( + client.queryRaw("telemetry", { refreshAuth: false }), + ).rejects.toMatchObject({ requestNotSent: true }) + expect(client.refreshTokenMethod).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalledWith( + EventType.MSG_CONNECTION_UNAUTHORIZED, + expect.anything(), + ) + expect(publish).not.toHaveBeenCalledWith(EventType.MSG_AUTH_REFRESH_FAILED) + expect(session.isCurrent()).toBe(true) + }) + + it("uses the shared current bearer without serializing the local option", async () => { + install(300_000) + const fetchMock = vi.fn<[string, RequestInit], Promise>(() => + Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ) + vi.stubGlobal("fetch", fetchMock) + await new Client().queryRaw("telemetry", { refreshAuth: false }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][0]).not.toContain("refreshAuth") + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + headers: { Authorization: "Bearer idle-user" }, + }) + }) +}) diff --git a/src/utils/questdb/client.nonSqlAuth.test.ts b/src/utils/questdb/client.nonSqlAuth.test.ts new file mode 100644 index 000000000..b0174104e --- /dev/null +++ b/src/utils/questdb/client.nonSqlAuth.test.ts @@ -0,0 +1,686 @@ +import "../../test/stubBrowserGlobals" +import { afterEach, describe, expect, it, vi } from "vitest" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { Client } from "./client" +import { tokenRefresh } from "./tokenRefresh" +import { + AuthRefreshUnavailableError, + SessionEndedError, +} from "../../modules/OAuth2/session" + +const commonHeaders = { + Authorization: "Bearer retained-credentials", + "X-Other": "keep", +} +const installToken = (generation: string, groupsEncoded: boolean) => { + authSessionState.setAuthPayload({ + access_token: `${generation}-access`, + id_token: `${generation}-id`, + token_type: "Bearer", + groups_encoded_in_token: groupsEncoded, + }) +} + +const fetchRequests = [ + { + name: "validateQuery", + url: "api/v1/sql/validate?query=select%201", + body: { query: "select 1", columns: [], timestamp: 0 }, + run: (client: Client) => client.validateQuery("select 1"), + }, + { + name: "checkCSVFile", + url: "chk?", + body: { status: "Exists" }, + run: (client: Client) => client.checkCSVFile("probe.csv"), + }, + { + name: "savePreferences", + url: "settings?version=1", + body: {}, + run: (client: Client) => + client.savePreferences({ version: 1, instance_name: "Test" }), + }, +] + +const mockUploadRequests = (autoComplete = true, sendError?: Error) => { + const requests: UploadRequest[] = [] + class UploadRequest { + status = 200 + statusText = "Upload failed" + response = JSON.stringify({ + columns: [], + header: true, + location: "probe", + rowsImported: 1, + rowsRejected: 0, + status: "OK", + }) + upload = { + addEventListener: vi.fn< + [string, (event: { loaded: number; total: number }) => void], + void + >(), + } + open = vi.fn() + setRequestHeader = vi.fn() + onload?: () => void + onerror?: () => void + onabort?: () => void + ontimeout?: () => void + abort = vi.fn(() => this.onabort?.()) + send = vi.fn(() => { + if (sendError) throw sendError + if (autoComplete) this.onload?.() + }) + + constructor() { + requests.push(this) + } + } + vi.stubGlobal("XMLHttpRequest", UploadRequest) + return requests +} + +const upload = (client: Client) => + client.uploadCSVFile({ + file: new File(["value\n1\n"], "probe.csv", { type: "text/csv" }), + name: "probe", + owner: "admin", + onProgress: vi.fn(), + }) + +const expectUploadHeaders = ( + request: ReturnType[number], + authorization: string, +) => { + expect(request.open).toHaveBeenCalledWith( + "POST", + expect.stringContaining("imp?"), + ) + expect(request.setRequestHeader.mock.calls).toEqual( + expect.arrayContaining([ + ["Authorization", authorization], + ["X-Other", "keep"], + ]), + ) + expect(request.setRequestHeader).toHaveBeenCalledTimes(2) + expect(request.send).toHaveBeenCalledWith(expect.any(FormData)) +} + +afterEach(() => { + authSessionState.endSession() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe.each([ + { name: "access token", groupsEncoded: false, suffix: "access" }, + { name: "ID token", groupsEncoded: true, suffix: "id" }, +])( + "non-SQL send-time authorization using the $name", + ({ groupsEncoded, suffix }) => { + it.each(fetchRequests)( + "$name sends the installed bearer after rotation and preserves other headers", + async ({ url, body, run }) => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(JSON.stringify(body))), + ) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders(commonHeaders) + installToken("initial", groupsEncoded) + await run(client) + // AuthProvider installs a renewed payload before React updates the + // client's common headers. Every entrypoint must read that new payload. + installToken("rotated", groupsEncoded) + await run(client) + + for (const [index, generation] of ["initial", "rotated"].entries()) { + expect(fetchMock).toHaveBeenNthCalledWith( + index + 1, + expect.stringContaining(url), + expect.objectContaining({ + headers: { + Authorization: `Bearer ${generation}-${suffix}`, + "X-Other": "keep", + }, + }), + ) + } + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(commonHeaders.Authorization).toBe("Bearer retained-credentials") + }, + ) + + it("uploadCSVFile sets the installed bearer on each XHR after rotation", async () => { + const requests = mockUploadRequests() + const client = new Client() + client.setCommonHeaders(commonHeaders) + installToken("initial", groupsEncoded) + await expect(upload(client)).resolves.toMatchObject({ status: "OK" }) + installToken("rotated", groupsEncoded) + await expect(upload(client)).resolves.toMatchObject({ status: "OK" }) + + expect(requests).toHaveLength(2) + expectUploadHeaders(requests[0], `Bearer initial-${suffix}`) + expectUploadHeaders(requests[1], `Bearer rotated-${suffix}`) + expect(commonHeaders.Authorization).toBe("Bearer retained-credentials") + }) + }, +) + +describe("non-SQL refresh coordination", () => { + it.each([...fetchRequests, { name: "uploadCSVFile", run: upload }])( + "$name waits for renewal and sends only the fresh bearer", + async ({ run }) => { + const requests = mockUploadRequests() + const fetchMock = vi.fn(() => Promise.resolve(new Response("{}"))) + vi.stubGlobal("fetch", fetchMock) + authSessionState.setAuthPayload({ + access_token: "expired", + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() - 1).toISOString(), + }) + const client = new Client() + let finish!: () => void + client.refreshTokenMethod = () => + new Promise((resolve) => { + finish = () => { + const payload = { + access_token: "fresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + 300_000).toISOString(), + } + authSessionState.setAuthPayload(payload) + resolve(payload) + } + }) + const pending = run(client) + await Promise.resolve() + expect(fetchMock).not.toHaveBeenCalled() + expect(requests).toHaveLength(0) + finish() + await pending + if (requests.length) { + expect(requests[0].setRequestHeader).toHaveBeenCalledWith( + "Authorization", + "Bearer fresh", + ) + } else { + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bearer fresh" }, + }), + ) + } + }, + ) +}) + +const operations = [...fetchRequests, { name: "uploadCSVFile", run: upload }] +const installExpired = () => + authSessionState.setAuthPayload({ + access_token: "expired", + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() - 1).toISOString(), + }) + +describe("non-SQL auth rejection branches", () => { + it.each(operations)( + "$name rejects unavailable renewal without sending a request", + async ({ run }) => { + vi.spyOn(console, "error").mockImplementation(() => {}) + const requests = mockUploadRequests() + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + installExpired() + const client = new Client() + client.refreshTokenMethod = vi.fn(() => + Promise.reject(new Error("IdP unavailable")), + ) + await expect(run(client)).rejects.toBeInstanceOf( + AuthRefreshUnavailableError, + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(requests).toHaveLength(0) + expect(authSessionState.isAuthenticated()).toBe(true) + expect(client.hasActiveRequests()).toBe(false) + }, + ) + + it.each(operations)( + "$name rejects a session end while waiting and never sends under the new login", + async ({ run }) => { + const requests = mockUploadRequests() + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + installExpired() + const client = new Client() + client.refreshTokenMethod = () => new Promise(() => {}) + const rejected = expect(run(client)).rejects.toBeInstanceOf( + SessionEndedError, + ) + await Promise.resolve() + authSessionState.endSession() + authSessionState.markNonSSOAuthenticated() + await rejected + expect(fetchMock).not.toHaveBeenCalled() + expect(requests).toHaveLength(0) + expect(client.hasActiveRequests()).toBe(false) + }, + ) + + it.each(operations)( + "$name rejects a response from the previous login", + async ({ name, run }) => { + const requests = mockUploadRequests(false) + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + installToken("initial", false) + const rejected = expect(run(new Client())).rejects.toBeInstanceOf( + SessionEndedError, + ) + await Promise.resolve() + await Promise.resolve() + authSessionState.endSession() + authSessionState.markNonSSOAuthenticated() + if (name === "uploadCSVFile") requests[0].onload!() + else finish(new Response("{}")) + await rejected + }, + ) + + it("validateQuery respects cancellation before and during a refresh wait", async () => { + installExpired() + const client = new Client() + client.refreshTokenMethod = () => new Promise(() => {}) + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + const signal = new AbortController() + const rejected = expect( + client.validateQuery("select 1", signal.signal), + ).rejects.toMatchObject({ name: "AbortError" }) + signal.abort() + await rejected + await expect( + client.validateQuery("select 1", signal.signal), + ).rejects.toMatchObject({ name: "AbortError" }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe("preferences write activity tracking", () => { + it.each(["success", "HTTP error", "invalid error body", "old session"])( + "tracks a pending write and clears it after %s", + async (outcome) => { + authSessionState.markNonSSOAuthenticated() + let finish!: (response: Response) => void + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + const pending = client + .savePreferences({ version: 1, instance_name: "Test" }) + .catch((error: unknown) => error) + expect(client.hasActiveRequests()).toBe(true) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + if (outcome === "old session") authSessionState.endSession() + finish( + outcome === "HTTP error" + ? new Response("Write failed", { status: 500 }) + : outcome === "invalid error body" + ? new Response("not JSON", { + status: 500, + statusText: "Server error", + headers: { "Content-Type": "application/json" }, + }) + : new Response("{}"), + ) + const result = await pending + expect(client.hasActiveRequests()).toBe(false) + if (outcome === "old session") { + expect(result).toBeInstanceOf(SessionEndedError) + } else { + expect(result).toMatchObject({ success: outcome === "success" }) + } + }, + ) + + it.each(["synchronous throw", "network rejection"])( + "clears activity after a %s", + async (outcome) => { + authSessionState.markNonSSOAuthenticated() + const error = new Error("Network failure") + vi.stubGlobal( + "fetch", + vi.fn(() => { + if (outcome === "synchronous throw") throw error + return Promise.reject(error) + }), + ) + const client = new Client() + await expect(client.savePreferences({ version: 1 })).rejects.toBe(error) + expect(client.hasActiveRequests()).toBe(false) + }, + ) + + it("counts overlapping writes independently", async () => { + authSessionState.markNonSSOAuthenticated() + const finish: ((response: Response) => void)[] = [] + const fetchMock = vi.fn( + () => new Promise((resolve) => finish.push(resolve)), + ) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + const first = client.savePreferences({ version: 1 }) + const second = client.savePreferences({ version: 1 }) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + finish[0](new Response("{}")) + await first + expect(client.hasActiveRequests()).toBe(true) + finish[1](new Response("{}")) + await second + expect(client.hasActiveRequests()).toBe(false) + }) + + it("keeps activity while reading an error response, including after logout", async () => { + authSessionState.markNonSSOAuthenticated() + let finish!: (body: string) => void + const text = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + headers: new Headers(), + text, + }), + ), + ) + const client = new Client() + const pending = client.savePreferences({ version: 1 }) + const rejected = expect(pending).rejects.toBeInstanceOf(SessionEndedError) + await vi.waitFor(() => expect(text).toHaveBeenCalledOnce()) + expect(client.hasActiveRequests()).toBe(true) + authSessionState.endSession() + expect(client.hasActiveRequests()).toBe(true) + finish("Write failed") + await rejected + expect(client.hasActiveRequests()).toBe(false) + }) +}) + +describe("upload activity tracking", () => { + it.each([ + "success", + "HTTP error", + "network error", + "abort", + "timeout", + "invalid JSON", + "old session", + ])("tracks a pending upload and clears it after %s", async (outcome) => { + authSessionState.markNonSSOAuthenticated() + const requests = mockUploadRequests(false) + const client = new Client() + const finished = upload(client).catch((error: unknown) => error) + await Promise.resolve() + await Promise.resolve() + expect(requests).toHaveLength(1) + expect(client.hasActiveRequests()).toBe(true) + const request = requests[0] + if (outcome === "HTTP error") request.status = 500 + if (outcome === "invalid JSON") request.response = "not JSON" + if (outcome === "old session") authSessionState.endSession() + if (outcome === "network error") request.onerror!() + else if (outcome === "abort") request.onabort!() + else if (outcome === "timeout") request.ontimeout!() + else request.onload!() + const result = await finished + if (outcome === "old session") + expect(result).toBeInstanceOf(SessionEndedError) + if (outcome === "invalid JSON") expect(result).toBeInstanceOf(SyntaxError) + expect(client.hasActiveRequests()).toBe(false) + }) + + it("clears activity when sending throws synchronously", async () => { + authSessionState.markNonSSOAuthenticated() + const error = new Error("Could not send") + mockUploadRequests(false, error) + const client = new Client() + await expect(upload(client)).rejects.toBe(error) + expect(client.hasActiveRequests()).toBe(false) + }) + + it("keeps concurrent uploads active until all have settled", async () => { + authSessionState.markNonSSOAuthenticated() + const requests = mockUploadRequests(false) + const client = new Client() + const first = upload(client) + const second = upload(client) + await Promise.resolve() + await Promise.resolve() + expect(client.hasActiveRequests()).toBe(true) + requests[0].onload!() + await first + expect(client.hasActiveRequests()).toBe(true) + requests[1].onload!() + await second + expect(client.hasActiveRequests()).toBe(false) + }) +}) + +describe("non-SQL write cancellation", () => { + const writes = [ + { name: "uploadCSVFile", run: upload }, + { + name: "savePreferences", + run: (client: Client) => client.savePreferences({ version: 1 }), + }, + ] + + it.each(writes)( + "cancels $name during renewal without sending or cancelling the shared refresh", + async ({ run }) => { + installExpired() + const requests = mockUploadRequests() + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + let finishRefresh!: () => void + let refreshSignal: AbortSignal | undefined + client.refreshTokenMethod = (_deadline, signal) => { + refreshSignal = signal + return new Promise((resolve) => { + finishRefresh = () => { + installToken("fresh", false) + resolve(authSessionState.getAuthPayload()!) + } + }) + } + const rejected = expect(run(client)).rejects.toMatchObject({ + name: "AbortError", + }) + await vi.waitFor(() => expect(finishRefresh).toBeDefined()) + expect(client.hasActiveRequests()).toBe(true) + expect(client.hasSentRequests()).toBe(false) + client.abort() + await rejected + expect(client.hasActiveRequests()).toBe(false) + expect(refreshSignal?.aborted).toBe(false) + finishRefresh() + await tokenRefresh.hardDeadline + expect(fetchMock).not.toHaveBeenCalled() + expect(requests).toHaveLength(0) + }, + ) + + it("aborts every upload, suppresses late progress and leaves a new upload active", async () => { + installToken("initial", false) + const requests = mockUploadRequests(false) + const client = new Client() + const progress = vi.fn() + const first = client + .uploadCSVFile({ + file: new File(["x"], "probe.csv"), + name: "probe", + owner: "admin", + onProgress: progress, + }) + .catch((error: unknown) => error) + const second = upload(client).catch((error: unknown) => error) + await vi.waitFor(() => expect(requests).toHaveLength(2)) + expect(client.hasSentRequests()).toBe(true) + const onProgress = requests[0].upload.addEventListener.mock.calls[0][1] + client.abort() + client.abort() + expect(requests[0].abort).toHaveBeenCalledOnce() + expect(requests[1].abort).toHaveBeenCalledOnce() + onProgress({ loaded: 1, total: 1 }) + expect(progress).not.toHaveBeenCalled() + const next = upload(client) + await Promise.all([first, second]) + await vi.waitFor(() => expect(requests).toHaveLength(3)) + requests[0].onload!() + expect(client.hasActiveRequests()).toBe(true) + expect(client.hasSentRequests()).toBe(true) + requests[2].onload!() + await next + expect(client.hasActiveRequests()).toBe(false) + expect(client.hasSentRequests()).toBe(false) + client.abort() + expect(requests[2].abort).not.toHaveBeenCalled() + }) + + it("cancels settings PUTs and their response bodies but not completed writes", async () => { + installToken("initial", false) + let signal: AbortSignal | null | undefined + const readBody = vi.fn() + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => { + signal = init?.signal + return Promise.resolve({ + ok: false, + status: 500, + headers: new Headers(), + text: () => { + readBody() + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + }, + }) + }), + ) + const client = new Client() + const rejected = expect( + client.savePreferences({ version: 1 }), + ).rejects.toMatchObject({ name: "AbortError" }) + await vi.waitFor(() => expect(readBody).toHaveBeenCalledOnce()) + expect(client.hasSentRequests()).toBe(true) + client.abort() + expect(signal?.aborted).toBe(true) + await rejected + expect(client.hasActiveRequests()).toBe(false) + expect(client.hasSentRequests()).toBe(false) + + vi.mocked(fetch).mockImplementation((_url, init) => { + signal = init?.signal + return Promise.resolve(new Response("{}")) + }) + await client.savePreferences({ version: 1 }) + client.abort() + expect(signal?.aborted).toBe(false) + }) + + it("keeps independent writes running when cancelling one SQL query", async () => { + installToken("initial", false) + const requests = mockUploadRequests(false) + let writeSignal: AbortSignal | null | undefined + vi.stubGlobal( + "fetch", + vi.fn((url: string, init?: RequestInit) => { + if (url.startsWith("settings?")) writeSignal = init?.signal + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + }), + ) + const client = new Client() + const uploadResult = upload(client).catch((error: unknown) => error) + const preferencesResult = client + .savePreferences({ version: 1 }) + .catch((error: unknown) => error) + const { promise, queryId } = client.queryRaw("SELECT 1", { + cancellable: true, + }) + const queryResult = promise.catch((error: unknown) => error) + await vi.waitFor(() => expect(requests).toHaveLength(1)) + client.abort(queryId) + await queryResult + expect(requests[0].abort).not.toHaveBeenCalled() + expect(writeSignal?.aborted).toBe(false) + expect(client.hasActiveRequests()).toBe(true) + client.abort() + await Promise.all([uploadResult, preferencesResult]) + expect(requests[0].abort).toHaveBeenCalledOnce() + expect(writeSignal?.aborted).toBe(true) + expect(client.hasActiveRequests()).toBe(false) + }) +}) + +describe("non-SQL authorization outside SSO", () => { + it.each(fetchRequests)("$name preserves common headers", async ({ run }) => { + authSessionState.markNonSSOAuthenticated() + const fetchMock = vi.fn(() => Promise.resolve(new Response("{}"))) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders(commonHeaders) + + await run(client) + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ headers: commonHeaders }), + ) + }) + + it("uploadCSVFile preserves common headers", async () => { + authSessionState.markNonSSOAuthenticated() + const requests = mockUploadRequests() + const client = new Client() + client.setCommonHeaders(commonHeaders) + + await upload(client) + + expect(requests).toHaveLength(1) + expectUploadHeaders(requests[0], commonHeaders.Authorization) + }) +}) diff --git a/src/utils/questdb/client.test.ts b/src/utils/questdb/client.test.ts index d8f3738e0..50459cb4d 100644 --- a/src/utils/questdb/client.test.ts +++ b/src/utils/questdb/client.test.ts @@ -3,8 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { Client } from "./client" import { stringifyWithBigInts } from "./serialize" import { Type } from "./types" -import type { QueryRawResult } from "./types" -import type { TableKind } from "./types" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" +import type { AuthPayload } from "../../modules/OAuth2/types" +import type { QueryRawResult, TableKind } from "./types" const response = (body: Record): Response => ({ @@ -15,6 +18,9 @@ const response = (body: Record): Response => afterEach(() => { vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + authSessionState.endSession() }) const rawDqlResult = ( @@ -157,6 +163,598 @@ describe("Client queryRaw NOTICE timings", () => { }) }) +const installToken = (overrides: Partial = {}): AuthPayload => { + const payload = { access_token: "new", token_type: "Bearer", ...overrides } + authSessionState.setAuthPayload(payload) + return payload +} + +describe("Client token refresh", () => { + const setExpiringToken = () => { + authSessionState.setAuthPayload({ + access_token: "stale", + refresh_token: "refresh", + expires_at: new Date(Date.now() + 20_000).toString(), + } as AuthPayload) + } + + it("keeps the current header on a rejected refresh and backs off", async () => { + setExpiringToken() + const fetchMock = vi.fn().mockResolvedValue(response({ notice: "hint" })) + vi.stubGlobal("fetch", fetchMock) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + const refresh = vi.fn().mockRejectedValue(new Error("network down")) + client.refreshTokenMethod = refresh + + // The triggering request still uses a valid token in the refresh window. + expect((await client.queryRaw("SELECT 1")).type).toBe(Type.NOTICE) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("exec?"), + expect.objectContaining({ + headers: { Authorization: "Bearer stale" }, + }), + ) + expect(refresh).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + "Failed to refresh the auth token", + expect.objectContaining({ message: "network down" }), + ) + expect(authSessionState.hasRefreshFailed()).toBe(true) + + // Subsequent queries use the current token without hammering the IdP. + await client.queryRaw("SELECT 2") + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it("notifies on retry failures so a dismissed warning can be restored", async () => { + vi.useFakeTimers() + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(response({ notice: "hint" })), + ) + vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + client.refreshTokenMethod = vi.fn().mockRejectedValue(new Error("offline")) + const onFailure = vi.fn() + eventBus.subscribe(EventType.MSG_AUTH_REFRESH_FAILED, onFailure) + try { + await client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(5_000) + await client.queryRaw("SELECT 2") + expect(client.refreshTokenMethod).toHaveBeenCalledTimes(2) + expect(onFailure).toHaveBeenCalledTimes(2) + } finally { + eventBus.unsubscribe(EventType.MSG_AUTH_REFRESH_FAILED, onFailure) + } + }) + + it("retries after backoff and resets it after a successful refresh", async () => { + vi.useFakeTimers() + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(response({ notice: "hint" })), + ) + vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("network down")) + .mockImplementationOnce(() => { + const payload = { + access_token: "new", + refresh_token: "rotated", + expires_at: new Date(Date.now() + 60_000).toISOString(), + } as AuthPayload + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + }) + .mockRejectedValue(new Error("offline again")) + client.refreshTokenMethod = refresh + + await client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(4_999) + await client.queryRaw("SELECT 2") + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await client.queryRaw("SELECT 3") + expect(refresh).toHaveBeenCalledTimes(2) + expect(authSessionState.hasRefreshFailed()).toBe(false) + expect(fetch).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer new" } }), + ) + // Once the renewed token enters its window, the next failure starts a + // new episode and must back off for 5 seconds, not 10. + await vi.advanceTimersByTimeAsync(31_000) + await client.queryRaw("SELECT 4") + expect(refresh).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(4_999) + await client.queryRaw("SELECT 5") + expect(refresh).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + await client.queryRaw("SELECT 6") + expect(refresh).toHaveBeenCalledTimes(4) + }) + + it("does not carry refresh backoff into a new SSO session", async () => { + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(response({ notice: "hint" })), + ) + vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockImplementationOnce(() => Promise.resolve(installToken())) + client.refreshTokenMethod = refresh + await client.queryRaw("SELECT 1") + + // A different auth payload means a fresh login, even inside the backoff. + authSessionState.setAuthPayload({ + access_token: "another-session", + refresh_token: "refresh", + expires_at: new Date(Date.now() + 20_000).toString(), + } as AuthPayload) + await client.queryRaw("SELECT 2") + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it("updates only future requests on success and preserves other headers", async () => { + // An in-flight request must keep its captured headers, but cannot delay + // the refresh or new queries for minutes while its body is still loading. + authSessionState.setAuthPayload({ + access_token: "stale", + refresh_token: "refresh", + expires_at: new Date(Date.now() + 60_000).toString(), + } as AuthPayload) + let finishFirst!: (value: Response) => void + const firstResponse = new Promise((resolve) => { + finishFirst = resolve + }) + const fetchMock = vi + .fn() + .mockReturnValueOnce(firstResponse) + .mockResolvedValue(response({ notice: "hint" })) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ + Authorization: "Bearer stale", + "X-Other": "keep", + }) + const first = client.queryRaw("SELECT 1") + expect(fetchMock).toHaveBeenCalledTimes(1) + setExpiringToken() + const refresh = vi.fn(() => + Promise.resolve( + installToken({ + access_token: "new", + id_token: "new-id", + groups_encoded_in_token: true, + }), + ), + ) + client.refreshTokenMethod = refresh + const second = client.queryRaw("SELECT 2") + expect((await second).type).toBe(Type.NOTICE) + expect(refresh).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bearer stale", "X-Other": "keep" }, + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bearer new-id", "X-Other": "keep" }, + }), + ) + finishFirst(response({ notice: "hint" })) + expect((await first).type).toBe(Type.NOTICE) + }) + + it("does not log out a refreshed session for an earlier request's 401", async () => { + vi.useFakeTimers() + authSessionState.setAuthPayload({ + access_token: "old", + refresh_token: "refresh", + expires_at: new Date(Date.now() + 60_000).toString(), + } as AuthPayload) + let finishFirst!: (value: Response) => void + const firstResponse = new Promise((resolve) => { + finishFirst = resolve + }) + const fetchMock = vi + .fn() + .mockReturnValueOnce(firstResponse) + .mockResolvedValue(response({ notice: "hint" })) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer old" }) + client.refreshTokenMethod = vi.fn(() => { + authSessionState.setAuthPayload({ + access_token: "new", + refresh_token: "next", + expires_at: new Date(Date.now() + 300_000).toString(), + } as AuthPayload) + return Promise.resolve(authSessionState.getAuthPayload()!) + }) + const onUnauthorized = vi.fn() + eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, onUnauthorized) + try { + const first = client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(31_000) + expect((await client.queryRaw("SELECT 2")).type).toBe(Type.NOTICE) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer new" } }), + ) + + finishFirst({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response) + expect((await first).type).toBe(Type.NOTICE) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(onUnauthorized).not.toHaveBeenCalled() + expect(authSessionState.getAuthPayload()?.access_token).toBe("new") + } finally { + eventBus.unsubscribe( + EventType.MSG_CONNECTION_UNAUTHORIZED, + onUnauthorized, + ) + } + }) + + it.each([ + { + name: "access token", + groupsEncoded: false, + oldAccess: "same-access", + nextAccess: "same-access", + oldId: "old-id", + nextId: "next-id", + }, + { + name: "ID token", + groupsEncoded: true, + oldAccess: "old-access", + nextAccess: "next-access", + oldId: "same-id", + nextId: "same-id", + }, + ])( + "reports a 401 after refresh when the $name bearer is unchanged", + async ({ groupsEncoded, oldAccess, nextAccess, oldId, nextId }) => { + vi.useFakeTimers() + const oldPayload = { + access_token: oldAccess, + id_token: oldId, + refresh_token: "refresh", + groups_encoded_in_token: groupsEncoded, + expires_at: new Date(Date.now() + 60_000).toString(), + } as AuthPayload + authSessionState.setAuthPayload(oldPayload) + + let finishFirst!: (value: Response) => void + const firstResponse = new Promise((resolve) => { + finishFirst = resolve + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockReturnValueOnce(firstResponse) + .mockResolvedValue(response({ notice: "hint" })), + ) + const client = new Client() + const bearer = groupsEncoded ? oldId : oldAccess + client.setCommonHeaders({ Authorization: `Bearer ${bearer}` }) + client.refreshTokenMethod = () => { + authSessionState.setAuthPayload({ + ...oldPayload, + access_token: nextAccess, + id_token: nextId, + expires_at: new Date(Date.now() + 300_000).toString(), + }) + return Promise.resolve(authSessionState.getAuthPayload()!) + } + const onUnauthorized = vi.fn() + eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, onUnauthorized) + try { + const first = client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(31_000) + await client.queryRaw("SELECT 2") + finishFirst({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response) + await expect(first).rejects.toMatchObject({ status: 401 }) + expect(onUnauthorized).toHaveBeenCalledTimes(1) + } finally { + eventBus.unsubscribe( + EventType.MSG_CONNECTION_UNAUTHORIZED, + onUnauthorized, + ) + } + }, + ) + + it("still reports a 401 from the current token", async () => { + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response), + ) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + const onUnauthorized = vi.fn() + eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, onUnauthorized) + try { + await expect(client.queryRaw("SELECT 1")).rejects.toMatchObject({ + status: 401, + }) + expect(onUnauthorized).toHaveBeenCalledTimes(1) + } finally { + eventBus.unsubscribe( + EventType.MSG_CONNECTION_UNAUTHORIZED, + onUnauthorized, + ) + } + }) + + it("starts SQL timing and the request callback after refresh", async () => { + vi.useFakeTimers() + setExpiringToken() + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve( + response({ + columns: [], + count: 0, + dataset: [], + timings: { + compiler: 0, + authentication: 0, + count: 0, + execute: 0, + }, + }), + ), + 20, + ) + }), + ) + vi.stubGlobal("fetch", fetchMock) + let resolveRefresh!: (token: AuthPayload) => void + const client = new Client() + client.refreshTokenMethod = () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + const onRequestStart = vi.fn() + const onRefreshWait = vi.fn() + const query = client.queryRaw("SELECT 1", undefined, { + onRequestStart, + onRefreshWait, + }) + await vi.advanceTimersByTimeAsync(5_000) + expect(onRefreshWait).toHaveBeenCalledTimes(1) + expect(onRequestStart).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + + resolveRefresh(installToken()) + await vi.advanceTimersByTimeAsync(20) + const result = await query + expect(onRefreshWait).toHaveBeenCalledTimes(1) + expect(onRequestStart).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + expect.not.stringMatching(/onRequestStart|onRefreshWait/), + expect.any(Object), + ) + expect(result.type).toBe(Type.DQL) + if (result.type !== Type.DQL) throw new Error("expected DQL") + expect(result.timings.fetch).toBe(20_000_000) + }) + + it("reports the refresh wait between a 401 and its retry", async () => { + // The browser thinks the token is valid, but the server rejects it once. + const issue = (access_token: string) => ({ + access_token, + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + 300_000).toISOString(), + }) + authSessionState.setAuthPayload(issue("stale")) + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response) + .mockResolvedValue(response({ notice: "hint" })) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + client.refreshTokenMethod = () => { + const payload = issue("fresh") + authSessionState.setAuthPayload(payload) + return Promise.resolve(payload) + } + const phases: string[] = [] + + const result = await client.queryRaw("SELECT 1", undefined, { + onRefreshWait: () => phases.push("refresh"), + onRequestStart: () => phases.push("request"), + }) + + expect(result.type).toBe(Type.NOTICE) + // A caller's request deadline must not run during the recovery refresh. + expect(phases).toEqual(["request", "refresh", "request"]) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.not.stringMatching(/onRequestStart|onRefreshWait/), + expect.objectContaining({ headers: { Authorization: "Bearer fresh" } }), + ) + }) + + it("releases queries across Client instances after one rejection", async () => { + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(response({ notice: "hint" })), + ) + vi.spyOn(console, "error").mockImplementation(() => {}) + let rejectRefresh!: (reason: Error) => void + const refresh = vi.fn().mockImplementation( + () => + new Promise((_, reject) => { + rejectRefresh = reject + }), + ) + const firstClient = new Client() + firstClient.refreshTokenMethod = refresh + const secondClient = new Client() + const first = firstClient.queryRaw("SELECT 1") + const second = secondClient.queryRaw("SELECT 2") + await Promise.resolve() + expect(refresh).toHaveBeenCalledTimes(1) + expect(fetch).not.toHaveBeenCalled() + rejectRefresh(new Error("offline")) + expect((await first).type).toBe(Type.NOTICE) + expect((await second).type).toBe(Type.NOTICE) + expect(refresh).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("lets a parked query cancel without cancelling the shared refresh", async () => { + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(response({ notice: "hint" })), + ) + const client = new Client() + let resolveRefresh!: (token: AuthPayload) => void + client.refreshTokenMethod = () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + const first = client.queryRaw("SELECT 1") + const { promise, queryId } = client.queryRaw("SELECT 2", { + cancellable: true, + }) + client.abort(queryId) + await expect(promise).rejects.toMatchObject({ error: "Cancelled by user" }) + expect(fetch).not.toHaveBeenCalled() + resolveRefresh(installToken()) + expect((await first).type).toBe(Type.NOTICE) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it("times out a refresh that never settles, then releases all waiters", async () => { + vi.useFakeTimers() + setExpiringToken() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(response({ notice: "hint" })), + ) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + const refresh = vi.fn( + (_signal: AbortSignal) => new Promise(() => {}), + ) + client.refreshTokenMethod = refresh + const first = client.queryRaw("SELECT 1") + const second = new Client().queryRaw("SELECT 2") + await Promise.resolve() + const signal = refresh.mock.calls[0][0] + await vi.advanceTimersByTimeAsync(10_000) + expect(signal?.aborted).toBe(true) + expect((await first).type).toBe(Type.NOTICE) + expect((await second).type).toBe(Type.NOTICE) + expect(errorSpy).toHaveBeenCalledWith( + "Failed to refresh the auth token", + expect.objectContaining({ message: "Request timed out" }), + ) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("installs a late rotated token without starting overlapping refreshes", async () => { + vi.useFakeTimers() + setExpiringToken() + const fetchMock = vi.fn().mockResolvedValue(response({ notice: "hint" })) + vi.stubGlobal("fetch", fetchMock) + vi.spyOn(console, "error").mockImplementation(() => {}) + let resolveRefresh!: (token: AuthPayload) => void + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + const refresh = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + client.refreshTokenMethod = refresh + const first = client.queryRaw("SELECT 1") + await vi.advanceTimersByTimeAsync(10_000) + await first + await vi.advanceTimersByTimeAsync(5_000) + await client.queryRaw("SELECT 2") + expect(refresh).toHaveBeenCalledTimes(1) + const latePayload = { + access_token: "late", + refresh_token: "rotated", + expires_at: new Date(Date.now() + 300_000).toISOString(), + } as AuthPayload + authSessionState.setAuthPayload(latePayload) + resolveRefresh(latePayload) + await vi.advanceTimersByTimeAsync(0) + await client.queryRaw("SELECT 3") + expect(fetchMock).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer late" } }), + ) + expect(authSessionState.hasRefreshFailed()).toBe(false) + }) + + it("does not overwrite the header when no token is installed", async () => { + setExpiringToken() + const fetchMock = vi.fn().mockResolvedValue(response({ notice: "hint" })) + vi.stubGlobal("fetch", fetchMock) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + client.refreshTokenMethod = vi.fn().mockResolvedValue(undefined) + await client.queryRaw("SELECT 1") + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer stale" } }), + ) + expect(errorSpy).not.toHaveBeenCalled() + }) +}) + describe("Client catalog method wiring", () => { const catalogResponse = ( tableName: string, @@ -248,3 +846,50 @@ describe("Client showDDL kind routing", () => { expect(sentQueries[3]).toContain("SHOW CREATE LIVE VIEW 'my_target';") }) }) + +describe("Client interruption tracking", () => { + const pendingFetch = () => + vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + + it("never counts sent background reads as interrupted statements", async () => { + const fetchMock = pendingFetch() + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + const reads: Array> = [ + client.queryRaw("tables();", { background: true }), + client.query("wal_tables()", { background: true }), + client.showTables(), + client.showColumns("trades"), + client.getTableDetails("trades"), + client.showMaterializedViews(), + client.showViews(), + client.showLiveViews(), + client.showDDL("trades", "table"), + ].map((request) => request.catch((error: unknown) => error)) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(9)) + for (const [url] of fetchMock.mock.calls) { + expect(url).not.toContain("background") + } + expect(client.hasActiveRequests()).toBe(false) + expect(client.hasSentRequests()).toBe(false) + + // CANCEL QUERY has a side effect, so it remains an uncertain statement. + const cancel = client + .cancelQuery(BigInt(42)) + .catch((error: unknown) => error) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(10)) + expect(client.hasActiveRequests()).toBe(true) + expect(client.hasSentRequests()).toBe(true) + + client.abort() + await Promise.all([...reads, cancel]) + expect(client.hasActiveRequests()).toBe(false) + }) +}) diff --git a/src/utils/questdb/client.ts b/src/utils/questdb/client.ts index 32b083980..cff6d6645 100644 --- a/src/utils/questdb/client.ts +++ b/src/utils/questdb/client.ts @@ -2,7 +2,15 @@ import { isServerError } from "../../utils" import { TelemetryConfigShape } from "../../store/Telemetry/types" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" -import { AuthPayload } from "../../modules/OAuth2/types" +import { + AuthRefreshUnavailableError, + endedFor, + sessionFailureOf, + SessionEndedError, + SESSION_ENDED_MESSAGE, +} from "../../modules/OAuth2/session" +import { CANCELLED_BY_USER } from "./cancellation" +import type { AuthSession } from "../../modules/OAuth2/authSessionState" import { API_VERSION } from "../../consts" import { Type, @@ -12,6 +20,7 @@ import { Table, Column, Options, + QueryLifecycle, RawResult, Release, NewsItem, @@ -31,10 +40,25 @@ import { ValidateQuerySuccessResult, ValidateQueryErrorResult, } from "./types" -import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { getBearerAuthorization } from "../../modules/OAuth2/utils" +import { + awaitRefresh, + isRefreshable, + RefreshTokenMethod, + RefreshableAuthPayload, + REFRESH_REQUEST_TIMEOUT_MS, + REFRESH_WAIT_TIMEOUT_MS, + tokenRefresh, +} from "./tokenRefresh" export type QueryId = number +type RequestExecution = { requestNotSent: boolean } + +// Catalog lookups read metadata only. Interrupting them needs no outcome check. +const BACKGROUND_READ: Options = { background: true } + export const escapeSqlLiteral = (value: string) => value.replace(/'/g, "''") export const buildDDLQuery = (name: string, kind: TableKind): string => { @@ -52,53 +76,47 @@ export const buildDDLQuery = (name: string, kind: TableKind): string => { } export class Client { - private _controllers = new Map() + private _controllers = new Map< + QueryId, + { + controller: AbortController + execution: RequestExecution + background: boolean + } + >() + // The value distinguishes a sent write from one still waiting for renewal. + private _writeControllers = new Map() private _nextQueryId: QueryId = 1 private _activeQueryId: QueryId | null = null private commonHeaders: Record = {} - private static refreshTokenPending = false - private static numOfPendingQueries = 0 - refreshTokenMethod: () => Promise> = async (): Promise< - Partial - > => { - return Promise.resolve({}) - } - - private tokenNeedsRefresh() { - const authPayload = ssoAuthState.getAuthPayload() - return ( - authPayload && - authPayload.refresh_token && - new Date(authPayload.expires_at).getTime() - new Date().getTime() < 30000 - ) - } + refreshTokenMethod?: RefreshTokenMethod setCommonHeaders(headers: Record) { this.commonHeaders = headers } - private refreshAuthToken = async () => { - Client.refreshTokenPending = true - await new Promise((resolve) => { - const interval = setInterval(async () => { - if (Client.numOfPendingQueries === 0) { - clearInterval(interval) - const newToken = await this.refreshTokenMethod() - if (newToken.access_token) { - this.setCommonHeaders({ - ...this.commonHeaders, - Authorization: `Bearer ${ - newToken.groups_encoded_in_token - ? newToken.id_token - : newToken.access_token - }`, - }) - } - Client.refreshTokenPending = false - return resolve(true) - } - }, 50) - }) + resetCredentials() { + this.setCommonHeaders({}) + this.refreshTokenMethod = undefined + } + + private currentAuthorization() { + const payload = authSessionState.getAuthPayload() + return payload + ? getBearerAuthorization(payload) + : this.commonHeaders.Authorization + } + + private requestHeaders() { + const { Authorization: _, ...headers } = this.commonHeaders + const authorization = this.currentAuthorization() + return authorization + ? { ...headers, Authorization: authorization } + : headers + } + + private startRefresh(payload: RefreshableAuthPayload) { + tokenRefresh.start(payload, this.refreshTokenMethod) } static encodeParams = ( @@ -116,9 +134,9 @@ export class Client { abort = (queryId?: QueryId) => { if (queryId !== undefined) { - const controller = this._controllers.get(queryId) - if (controller) { - controller.abort() + const request = this._controllers.get(queryId) + if (request) { + request.controller.abort() this._controllers.delete(queryId) } if (this._activeQueryId === queryId) { @@ -126,11 +144,35 @@ export class Client { } return } - this._controllers.forEach((controller) => controller.abort()) + this._controllers.forEach(({ controller }) => controller.abort()) this._controllers.clear() + this._writeControllers.forEach((_, controller) => controller.abort()) + this._writeControllers.clear() this._activeQueryId = null } + // Background reads are cancelled with the session without asking: only + // statements the user ran and writes make ending the session disruptive. + hasActiveRequests = () => { + for (const { background } of this._controllers.values()) { + if (!background) return true + } + return this._writeControllers.size > 0 + } + + // Refresh waiters and SQL rejected with 401 have definitively not executed, + // and background reads change nothing. Only warn about uncertainty if a user + // statement or write was actually sent. + hasSentRequests = () => { + for (const { execution, background } of this._controllers.values()) { + if (!background && !execution.requestNotSent) return true + } + for (const sent of this._writeControllers.values()) { + if (sent) return true + } + return false + } + abortActive = () => { if (this._activeQueryId !== null) { this.abort(this._activeQueryId) @@ -217,19 +259,37 @@ export class Client { queryRaw( query: string, options: Options & { cancellable: true }, + lifecycle?: QueryLifecycle, ): { promise: Promise; queryId: QueryId } - queryRaw(query: string, options?: Options): Promise queryRaw( query: string, options?: Options, + lifecycle?: QueryLifecycle, + ): Promise + queryRaw( + query: string, + options?: Options, + lifecycle?: QueryLifecycle, ): | Promise | { promise: Promise; queryId: QueryId } { const queryId = this._nextQueryId++ const controller = new AbortController() - this._controllers.set(queryId, controller) + const execution = { requestNotSent: true } + this._controllers.set(queryId, { + controller, + execution, + background: options?.background === true, + }) - const promise = this._executeQueryRaw(query, options, controller, queryId) + const promise = this._executeQueryRaw( + query, + options, + controller, + queryId, + execution, + lifecycle, + ) if (options?.cancellable) { this._activeQueryId = queryId @@ -239,13 +299,142 @@ export class Client { return promise } + private ensureRequestAllowed(session: AuthSession, signal: AbortSignal) { + if (!session.isCurrent()) throw new SessionEndedError() + if (signal.aborted) throw new DOMException("Aborted", "AbortError") + tokenRefresh.ensureUsableToken() + } + + private refreshBeforeRequest( + signal: AbortSignal, + lifecycle?: QueryLifecycle, + ): { waited: false } | { waited: true; ready: Promise } { + tokenRefresh.syncSession() + const payload = authSessionState.getAuthPayload() + if (isRefreshable(payload) && tokenRefresh.needsRefresh(payload)) { + this.startRefresh(payload) + } + const deadline = tokenRefresh.softDeadline + if (!deadline) return { waited: false } + lifecycle?.onRefreshWait?.(REFRESH_WAIT_TIMEOUT_MS) + return { waited: true, ready: awaitRefresh(deadline, signal) } + } + + private async prepareRequest(signal = new AbortController().signal) { + const session = authSessionState.capture() + const refresh = this.refreshBeforeRequest(signal) + if (refresh.waited) await refresh.ready + this.ensureRequestAllowed(session, signal) + return session + } + + private async sendQuery( + payload: Record, + signal: AbortSignal, + session: AuthSession, + execution: RequestExecution, + lifecycle?: QueryLifecycle, + refreshAuth = true, + ) { + const originalPayload = authSessionState.getAuthPayload() + if (!refreshAuth && !tokenRefresh.canSendWithoutRefresh()) + throw new AuthRefreshUnavailableError() + const refresh = refreshAuth + ? this.refreshBeforeRequest(signal, lifecycle) + : { waited: false as const } + if (refresh.waited) await refresh.ready + const waitForHardDeadline = async () => { + const deadline = tokenRefresh.hardDeadline + if (!deadline) return + lifecycle?.onRefreshWait?.(REFRESH_REQUEST_TIMEOUT_MS) + await awaitRefresh(deadline, signal) + } + const ensureCanSend = () => this.ensureRequestAllowed(session, signal) + let authorizationAtFetch: string | undefined + let fetchStartedAt = 0 + const send = async () => { + ensureCanSend() + lifecycle?.onRequestStart?.() + // Read the installed bearer immediately before every authenticated fetch. + const headers = this.requestHeaders() + authorizationAtFetch = headers.Authorization + fetchStartedAt = Date.now() + execution.requestNotSent = false + const response = await fetch(`exec?${Client.encodeParams(payload)}`, { + signal, + headers, + }) + // Authentication/authorization rejection executed no SQL. Record that + // before publishing connection events, which may synchronously log out. + // Preserve it until another SQL attempt is sent. + if (response.status === 401 || response.status === 403) + execution.requestNotSent = true + return response + } + const payloadAtFetch = authSessionState.getAuthPayload() + let response = await send() + if (response.status === 401 && originalPayload) { + // Non-interactive work must neither refresh nor end the user's login. + if (!refreshAuth) throw new AuthRefreshUnavailableError() + if (!session.isCurrent()) throw new SessionEndedError() + const currentPayload = authSessionState.getAuthPayload() + const currentAuthorization = this.currentAuthorization() + let waitForRefresh = !!tokenRefresh.hardDeadline + if (!waitForRefresh) { + if ( + currentAuthorization && + currentAuthorization !== authorizationAtFetch + ) { + // A 401 executed no SQL. Resend at most once with an already-rotated bearer. + response = await send() + } else if ( + isRefreshable(currentPayload) && + authSessionState.hasRefreshFailed() && + (tokenRefresh.inBackoff() || refresh.waited) + ) { + throw new AuthRefreshUnavailableError() + } else if ( + !refresh.waited && + isRefreshable(currentPayload) && + payloadAtFetch === currentPayload && + authorizationAtFetch === currentAuthorization && + this.refreshTokenMethod + ) { + // Server-side 401s bypass clock-skew/success throttling. Never + // cancel an ongoing rotating refresh just because SQL rejected its bearer. + this.startRefresh(currentPayload) + waitForRefresh = true + } + } + if (waitForRefresh) { + await waitForHardDeadline() + ensureCanSend() + if (authSessionState.hasRefreshFailed()) + throw new AuthRefreshUnavailableError() + if ( + this.currentAuthorization() !== authorizationAtFetch || + authSessionState.getAuthPayload() !== payloadAtFetch + ) + response = await send() + } + } + return { response, authorizationAtFetch, fetchStartedAt } + } + private async _executeQueryRaw( query: string, options: Options | undefined, controller: AbortController, queryId: QueryId, + execution: RequestExecution, + lifecycle?: QueryLifecycle, ): Promise { - const { cancellable: _, ...queryOptions } = options ?? {} + const { + cancellable: _, + background: _background, + refreshAuth = true, + ...queryOptions + } = options ?? {} const payload = { count: true, src: "con", @@ -256,33 +445,35 @@ export class Client { } let response: Response - - if (this.tokenNeedsRefresh() && !Client.refreshTokenPending) { - await this.refreshAuthToken() - } - - if (Client.refreshTokenPending) { - await new Promise((resolve) => { - const interval = setInterval(() => { - if (!Client.refreshTokenPending) { - clearInterval(interval) - return resolve(true) - } - }, 50) - }) + let fetchStartedAt = 0 + let authorizationAtFetch: string | undefined + const session = authSessionState.capture() + const ensureCurrentSession = () => { + if (!session.isCurrent()) throw new SessionEndedError() } + const sessionEndedResult = (): ErrorResult => ({ + position: -1, + query, + type: Type.ERROR, + error: SESSION_ENDED_MESSAGE, + sessionEnded: true, + ...sessionFailureOf(execution), + }) - Client.numOfPendingQueries++ - - const start = new Date() try { - response = await fetch(`exec?${Client.encodeParams(payload)}`, { - signal: controller.signal, - headers: this.commonHeaders, - }) + const sent = await this.sendQuery( + payload, + controller.signal, + session, + execution, + lifecycle, + refreshAuth, + ) + response = sent.response + authorizationAtFetch = sent.authorizationAtFetch + fetchStartedAt = sent.fetchStartedAt } catch (error) { this.removeController(queryId) - Client.numOfPendingQueries-- const err = { position: -1, @@ -293,6 +484,20 @@ export class Client { const genericErrorPayload = { ...err, error: "An error occurred, please try again", + connectionError: true, + } + + if (endedFor(session, error)) { + return Promise.reject(sessionEndedResult()) + } + + if (error instanceof AuthRefreshUnavailableError) { + return Promise.reject({ + ...err, + error: error.message, + authRefreshUnavailable: true, + requestNotSent: true, + }) } if (error instanceof DOMException) { @@ -300,8 +505,9 @@ export class Client { ...err, error: error.code === 20 - ? "Cancelled by user" + ? CANCELLED_BY_USER : JSON.stringify(error).toString(), + ...sessionFailureOf(execution), }) } @@ -311,6 +517,9 @@ export class Client { } try { + // Fence all completions, body reads and failures against the login that + // launched the request before publishing or returning anything. + ensureCurrentSession() if ( response.ok || response.status === 400 || @@ -320,12 +529,13 @@ export class Client { try { responseText = await response.text() } catch (error) { + ensureCurrentSession() if (error instanceof DOMException && error.name === "AbortError") { return Promise.reject({ position: -1, query, type: Type.ERROR, - error: "Cancelled by user", + error: CANCELLED_BY_USER, }) } return Promise.reject({ @@ -333,7 +543,8 @@ export class Client { type: Type.ERROR, }) } - const fetchTime = (new Date().getTime() - start.getTime()) * 1e6 + ensureCurrentSession() + const fetchTime = (Date.now() - fetchStartedAt) * 1e6 let data try { data = JSON.parse(responseText) as RawResult @@ -345,6 +556,7 @@ export class Client { } eventBus.publish(EventType.MSG_CONNECTION_OK) + ensureCurrentSession() if (response.status === 403) { eventBus.publish(EventType.MSG_CONNECTION_FORBIDDEN, data) @@ -364,14 +576,14 @@ export class Client { } } - if (data.error) { + if (data.error !== undefined) { return Promise.reject({ ...data, type: Type.ERROR, }) } - if (data.notice) { + if (data.notice !== undefined) { return { ...data, ...(data.timings @@ -391,7 +603,7 @@ export class Client { } } - const errorPayload: Record = { + const errorPayload: Record = { status: response.status, error: response.statusText, } @@ -401,12 +613,21 @@ export class Client { errorPayload.position = -1 errorPayload.query = query errorPayload.type = Type.ERROR + errorPayload.connectionError = true eventBus.publish(EventType.MSG_CONNECTION_ERROR, errorPayload) } if (response.status === 401) { errorPayload.error = `Unauthorized` - eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED, errorPayload) + const currentAuthorization = this.currentAuthorization() + // Reject an older request without logging out a session using a + // different bearer. A refresh can also return the same bearer value. + if ( + session.isCurrent() && + authorizationAtFetch === currentAuthorization + ) { + eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED, errorPayload) + } } if (response.status === 403) { @@ -414,16 +635,18 @@ export class Client { try { errorText = (await response.text()).trim() } catch (error) { + ensureCurrentSession() if (error instanceof DOMException && error.name === "AbortError") { return Promise.reject({ position: -1, query, type: Type.ERROR, - error: "Cancelled by user", + error: CANCELLED_BY_USER, }) } throw error } + ensureCurrentSession() if (errorText.startsWith("{")) { const data = JSON.parse(errorText) as ErrorResult errorPayload.error = data.error @@ -433,10 +656,15 @@ export class Client { eventBus.publish(EventType.MSG_CONNECTION_FORBIDDEN, errorPayload) } + ensureCurrentSession() return Promise.reject(errorPayload) + } catch (error) { + if (endedFor(session, error)) { + return Promise.reject(sessionEndedResult()) + } + throw error } finally { this.removeController(queryId) - Client.numOfPendingQueries-- } } @@ -444,19 +672,22 @@ export class Client { query: string, signal?: AbortSignal, ): Promise { + const session = await this.prepareRequest(signal) + this.ensureRequestAllowed(session, signal ?? new AbortController().signal) const response = await fetch( `api/v1/sql/validate?${Client.encodeParams({ query })}`, { - headers: this.commonHeaders, + headers: this.requestHeaders(), signal, }, ) - if (response.ok) { - return (await response.json()) as ValidateQuerySuccessResult - } - - if (response.status === 400 || response.status === 403) { - return (await response.json()) as ValidateQueryErrorResult + if (!session.isCurrent()) throw new SessionEndedError() + if (response.ok || response.status === 400 || response.status === 403) { + const result = (await response.json()) as + | ValidateQuerySuccessResult + | ValidateQueryErrorResult + if (!session.isCurrent()) throw new SessionEndedError() + return result } return Promise.reject({ @@ -466,7 +697,10 @@ export class Client { } async showTables(): Promise> { - const response = await this.queryCatalog("tables();") + const response = await this.queryCatalog
( + "tables();", + BACKGROUND_READ, + ) if (response.type === Type.DQL) { return { @@ -491,11 +725,17 @@ export class Client { } async showPermissions(user: string): Promise> { - return await this.query(`SHOW PERMISSIONS '${user}';`) + return await this.query( + `SHOW PERMISSIONS '${user}';`, + BACKGROUND_READ, + ) } async showColumns(table: string): Promise> { - return await this.query(`SHOW COLUMNS FROM '${table}';`) + return await this.query( + `SHOW COLUMNS FROM '${table}';`, + BACKGROUND_READ, + ) } async showSymbolColumnDetails( @@ -510,25 +750,30 @@ export class Client { > { return await this.query( `WITH cols as (SHOW COLUMNS FROM '${table}') SELECT symbolCached, symbolCapacity, indexed FROM cols WHERE column = '${column}';`, + BACKGROUND_READ, ) } async getTableDetails(table: string): Promise> { return await this.queryCatalog
( `tables() where table_name = '${escapeSqlLiteral(table)}';`, + BACKGROUND_READ, ) } async showMaterializedViews(): Promise> { - return await this.queryCatalog("materialized_views()") + return await this.queryCatalog( + "materialized_views()", + BACKGROUND_READ, + ) } async showViews(): Promise> { - return await this.query("views();") + return await this.query("views();", BACKGROUND_READ) } async showLiveViews(): Promise> { - return await this.queryCatalog("live_views();") + return await this.queryCatalog("live_views();", BACKGROUND_READ) } async cancelQuery(queryId: bigint): Promise> { @@ -543,7 +788,7 @@ export class Client { } private async queryDDL(sql: string): Promise> { - const result = await this.query<{ ddl: string }>(sql) + const result = await this.query<{ ddl: string }>(sql, BACKGROUND_READ) if (result.type === Type.DQL) { result.data = result.data.map((row) => ({ ...row, @@ -554,15 +799,19 @@ export class Client { } async checkCSVFile(name: string): Promise { + const session = await this.prepareRequest() + if (!session.isCurrent()) throw new SessionEndedError() const response = await fetch( `chk?${Client.encodeParams({ f: "json", j: name, version: API_VERSION, })}`, - { headers: this.commonHeaders }, + { headers: this.requestHeaders() }, ) - return (await response.json()) as FileCheckResponse + const result = (await response.json()) as FileCheckResponse + if (!session.isCurrent()) throw new SessionEndedError() + return result } async uploadCSVFile({ @@ -575,78 +824,136 @@ export class Client { timestamp, onProgress, }: UploadOptions): Promise { - const formData = new FormData() - if (schema) { - formData.append("schema", JSON.stringify(schema)) - } - formData.append("data", file) - const serializedSettings = settings - ? Object.keys(settings).reduce( - (acc, key) => ({ - ...acc, - [key]: settings[key as keyof UploadModeSettings].toString(), - }), - {}, - ) - : {} - const params = { - fmt: "json", - name, - owner, - ...(partitionBy ? { partitionBy } : {}), - ...(timestamp ? { timestamp } : {}), - ...serializedSettings, - } - - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest() - request.open("POST", `imp?${new URLSearchParams(params)}`) - Object.keys(this.commonHeaders).forEach((key) => { - request.setRequestHeader(key, this.commonHeaders[key]) - }) - request.upload.addEventListener("progress", (e) => { - const percent_completed = (e.loaded / e.total) * 100 - onProgress(percent_completed) - }) - request.onload = (_e) => { - if (request.status === 200) { - resolve(JSON.parse(request.response as string) as UploadResult) - } else { - reject({ - status: request.status, - statusText: request.statusText, - }) - } + const controller = new AbortController() + this._writeControllers.set(controller, false) + let removeAbortListener: (() => void) | undefined + const session = authSessionState.capture() + try { + await this.prepareRequest(controller.signal) + this.ensureRequestAllowed(session, controller.signal) + const formData = new FormData() + if (schema) { + formData.append("schema", JSON.stringify(schema)) } - request.onerror = () => { - reject({ - status: request.status, - statusText: request.statusText, - }) + formData.append("data", file) + const serializedSettings = settings + ? Object.keys(settings).reduce( + (acc, key) => ({ + ...acc, + [key]: settings[key as keyof UploadModeSettings].toString(), + }), + {}, + ) + : {} + const params = { + fmt: "json", + name, + owner, + ...(partitionBy ? { partitionBy } : {}), + ...(timestamp ? { timestamp } : {}), + ...serializedSettings, } - request.send(formData) - }) + + return await new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.open("POST", `imp?${new URLSearchParams(params)}`) + const headers = this.requestHeaders() + Object.keys(headers).forEach((key) => { + request.setRequestHeader(key, headers[key]) + }) + request.upload.addEventListener("progress", (e) => { + if (!session.isCurrent() || controller.signal.aborted) return + const percent_completed = (e.loaded / e.total) * 100 + onProgress(percent_completed) + }) + const rejectRequest = () => { + reject( + session.isCurrent() + ? { status: request.status, statusText: request.statusText } + : new SessionEndedError(), + ) + } + request.onload = () => { + if (!session.isCurrent() || controller.signal.aborted) { + rejectRequest() + return + } + if (request.status === 200) { + try { + resolve(JSON.parse(request.response as string) as UploadResult) + } catch (error) { + reject(error) + } + } else { + rejectRequest() + } + } + request.onerror = rejectRequest + request.onabort = rejectRequest + request.ontimeout = rejectRequest + const abort = () => { + request.abort() + rejectRequest() + } + controller.signal.addEventListener("abort", abort, { once: true }) + removeAbortListener = () => + controller.signal.removeEventListener("abort", abort) + this._writeControllers.set(controller, true) + request.send(formData) + }) + } catch (error) { + if (endedFor(session, error)) throw new SessionEndedError() + throw error + } finally { + removeAbortListener?.() + this._writeControllers.delete(controller) + } } async savePreferences( preferences: Preferences, ): Promise<{ status: number; message?: string; success: boolean }> { - const { version, ...prefs } = preferences - const response: Response = await fetch(`settings?version=${version}`, { - method: "PUT", - headers: this.commonHeaders, - body: JSON.stringify(prefs), - }) - if (!response.ok) { - let errorMessage: string - try { - errorMessage = await extractErrorMessage(response) - } catch (e) { - errorMessage = response.statusText + const controller = new AbortController() + this._writeControllers.set(controller, false) + const session = authSessionState.capture() + try { + await this.prepareRequest(controller.signal) + this.ensureRequestAllowed(session, controller.signal) + const { version, ...prefs } = preferences + this._writeControllers.set(controller, true) + const response: Response = await fetch(`settings?version=${version}`, { + method: "PUT", + signal: controller.signal, + headers: this.requestHeaders(), + body: JSON.stringify(prefs), + }) + if (!session.isCurrent()) throw new SessionEndedError() + if (controller.signal.aborted) + throw new DOMException("Aborted", "AbortError") + if (!response.ok) { + let errorMessage: string + try { + errorMessage = await extractErrorMessage(response) + } catch (error) { + if (controller.signal.aborted) throw error + errorMessage = response.statusText + } + if (!session.isCurrent()) throw new SessionEndedError() + if (controller.signal.aborted) + throw new DOMException("Aborted", "AbortError") + return { + status: response.status, + message: errorMessage, + success: false, + } } - return { status: response.status, message: errorMessage, success: false } + return { status: response.status, success: true } + } catch (error) { + if (endedFor(session, error)) throw new SessionEndedError() + throw error + } finally { + this._writeControllers.delete(controller) } - return { status: response.status, success: true } } async getLatestRelease() { diff --git a/src/utils/questdb/queryClient.ts b/src/utils/questdb/queryClient.ts new file mode 100644 index 000000000..be1d42b62 --- /dev/null +++ b/src/utils/questdb/queryClient.ts @@ -0,0 +1,10 @@ +import { Client } from "./client" +import { authSessionState } from "../../modules/OAuth2/authSessionState" + +// Console and telemetry SQL share credentials and refresh handling. Configure +// this instance in QuestProvider before starting any store telemetry epics. +export const questClient = new Client() +authSessionState.onEnd(() => { + questClient.abort() + questClient.resetCredentials() +}) diff --git a/src/utils/questdb/queryExecution.ts b/src/utils/questdb/queryExecution.ts new file mode 100644 index 000000000..246728afc --- /dev/null +++ b/src/utils/questdb/queryExecution.ts @@ -0,0 +1,5 @@ +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { QueryExecutionManager } from "./queryExecutionManager" + +export const questExecution = new QueryExecutionManager() +authSessionState.onEnd(questExecution.reset) diff --git a/src/utils/questdb/queryExecutionManager.session.test.ts b/src/utils/questdb/queryExecutionManager.session.test.ts new file mode 100644 index 000000000..56a47f9f1 --- /dev/null +++ b/src/utils/questdb/queryExecutionManager.session.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest" +import { QueryExecutionManager } from "./queryExecutionManager" + +describe("query execution reset", () => { + it("aborts all scopes and discards pending confirmations", async () => { + const manager = new QueryExecutionManager() + const abort = vi.fn() + const execute = vi.fn() + const dismiss = vi.fn() + manager.markActive(1, "active@1-1", abort) + manager.markActive(2, "other@1-1", abort, "notebook") + manager.requestExecution({ + bufferId: 1, + queryKey: "pending@1-1", + abort, + execute, + onDismiss: dismiss, + }) + manager.reset() + await manager.confirmPending() + expect(abort).toHaveBeenCalledTimes(2) + expect(dismiss).toHaveBeenCalledOnce() + expect(execute).not.toHaveBeenCalled() + expect(manager.getSnapshot()).toEqual({ + active: null, + pending: null, + dialogOpen: false, + }) + expect(manager.isAnyRunning("notebook")).toBe(false) + }) + + it("invalidates an already-confirmed request waiting for the previous query to settle", async () => { + const manager = new QueryExecutionManager() + const execute = vi.fn() + const dismiss = vi.fn() + manager.markActive(1, "active@1-1", vi.fn()) + manager.requestExecution({ + bufferId: 1, + queryKey: "pending@1-1", + abort: vi.fn(), + execute, + onDismiss: dismiss, + }) + const confirming = manager.confirmPending() + manager.reset() + await confirming + expect(execute).not.toHaveBeenCalled() + expect(dismiss).toHaveBeenCalledOnce() + expect(manager.isAnyRunning()).toBe(false) + }) +}) diff --git a/src/utils/questdb/queryExecutionManager.ts b/src/utils/questdb/queryExecutionManager.ts index 7c6db0bb3..b20d1749b 100644 --- a/src/utils/questdb/queryExecutionManager.ts +++ b/src/utils/questdb/queryExecutionManager.ts @@ -44,6 +44,20 @@ export class QueryExecutionManager { } private _idleWaiters = new Map void>>() + private generation = 0 + + reset = (): void => { + this.generation++ + const active = [...this._activeByScope.values()] + const pending = [...this._pendingByScope.values()] + this._activeByScope.clear() + this._pendingByScope.clear() + this._dialogScopeKey = null + active.forEach((execution) => execution.abort()) + pending.forEach((execution) => execution.onDismiss?.()) + for (const scope of this._idleWaiters.keys()) this.flushIdleWaiters(scope) + this.refreshSnapshot() + } private waitForIdle(scopeKey: ScopeKey): Promise { if (!this._activeByScope.has(scopeKey)) return Promise.resolve() @@ -178,9 +192,14 @@ export class QueryExecutionManager { // (which also dispatches stopRunning in its handler) before starting the // pending query. Otherwise execute() can fire toggleRunning(QUERY) while // running is still QUERY, producing a no-op useEffect. + const generation = this.generation const idle = this.waitForIdle(scopeKey) this.abortActive(scopeKey) await idle + if (this.generation !== generation) { + pending.onDismiss?.() + return + } this._activeByScope.set(scopeKey, { scopeKey, diff --git a/src/utils/questdb/tokenRefresh.ts b/src/utils/questdb/tokenRefresh.ts new file mode 100644 index 000000000..8c0881491 --- /dev/null +++ b/src/utils/questdb/tokenRefresh.ts @@ -0,0 +1,225 @@ +import { AuthPayload } from "../../modules/OAuth2/types" +import { AuthRefreshUnavailableError } from "../../modules/OAuth2/session" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { TOKEN_REQUEST_TIMEOUT_MS } from "../../modules/OAuth2/utils" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" +import { DeadlineExceededError, withDeadline } from "../withDeadline" + +// Queries wait this long, not the network request. A late success may contain +// the only usable rotated refresh token and is retained until the hard limit. +export const REFRESH_WAIT_TIMEOUT_MS = TOKEN_REQUEST_TIMEOUT_MS +export const REFRESH_REQUEST_TIMEOUT_MS = 60_000 +export const REFRESH_RETRY_DELAY_MS = 5_000 +const MAX_RETRY_DELAY_MS = 30_000 +const REFRESH_WINDOW_MS = 30_000 +const MIN_SUCCESS_THROTTLE_MS = 5_000 +const MAX_SUCCESS_THROTTLE_MS = 30_000 + +export type RefreshableAuthPayload = AuthPayload & { + refresh_token: string + expires_at: string +} + +// Both signals are required: one marks late OAuth errors, the other cancels +// network work at the hard deadline/session change. Only an installed payload +// may be returned; a raw endpoint response is not a valid refreshed session. +export type RefreshTokenMethod = ( + deadlineSignal: AbortSignal, + requestSignal: AbortSignal, +) => Promise + +export const isRefreshable = ( + payload: AuthPayload | null, +): payload is RefreshableAuthPayload => + !!payload?.refresh_token && + payload.expires_at !== undefined && + Number.isFinite(new Date(payload.expires_at).getTime()) + +class TokenRefreshCoordinator { + private pending: { + softDeadline: Promise + hardDeadline: Promise + sessionId: number + cancel: () => void + } | null = null + private retryPayload: AuthPayload | null = null + private failures = 0 + private retryAfter = 0 + + private resetBackoff(payload: AuthPayload | null) { + this.retryPayload = payload + this.failures = 0 + this.retryAfter = 0 + } + + syncSession() { + if ( + this.pending && + this.pending.sessionId !== authSessionState.getSessionId() + ) { + this.pending.cancel() + this.pending = null + } + const payload = authSessionState.getAuthPayload() + if (this.retryPayload !== payload) this.resetBackoff(payload) + } + + get softDeadline() { + return this.pending?.softDeadline + } + + get hardDeadline() { + return this.pending?.hardDeadline + } + + inBackoff() { + return Date.now() < this.retryAfter + } + + needsRefresh(payload: AuthPayload | null) { + return ( + isRefreshable(payload) && + new Date(payload.expires_at).getTime() - Date.now() < REFRESH_WINDOW_MS && + Date.now() >= this.retryAfter + ) + } + + canSendWithoutRefresh() { + const payload = authSessionState.getAuthPayload() + return ( + !payload || + (!this.pending && + !authSessionState.hasRefreshFailed() && + (!isRefreshable(payload) || + new Date(payload.expires_at).getTime() - Date.now() >= + REFRESH_WINDOW_MS)) + ) + } + + // Never send a known-expired bearer after a transient refresh failure. + // Keep the login intact: transport/5xx failures do not prove revocation. + // Authoritative OAuth rejections are handled by AuthProvider. + ensureUsableToken() { + const payload = authSessionState.getAuthPayload() + if ( + isRefreshable(payload) && + authSessionState.hasRefreshFailed() && + new Date(payload.expires_at).getTime() <= Date.now() + ) { + throw new AuthRefreshUnavailableError() + } + } + + start( + originalPayload: RefreshableAuthPayload, + refresh: RefreshTokenMethod | undefined, + ) { + if (this.pending || !refresh) return + const sessionId = authSessionState.getSessionId() + const deadline = new AbortController() + const requestController = new AbortController() + const expiry = new Date(originalPayload.expires_at).getTime() + let failureReported = false + const reportFailure = (error: unknown) => { + if ( + failureReported || + authSessionState.getAuthPayload() !== originalPayload || + authSessionState.getSessionId() !== sessionId + ) + return + failureReported = true + this.failures++ + const now = Date.now() + const retryAfter = + now + + Math.min( + REFRESH_RETRY_DELAY_MS * 2 ** (this.failures - 1), + MAX_RETRY_DELAY_MS, + ) + this.retryAfter = expiry > now ? Math.min(expiry, retryAfter) : retryAfter + console.error("Failed to refresh the auth token", error) + authSessionState.markRefreshFailed() + eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED) + } + const request = Promise.resolve() + .then(() => refresh(deadline.signal, requestController.signal)) + .then((payload) => { + if ( + requestController.signal.aborted || + !payload || + !authSessionState.getAuthPayload() || + authSessionState.getSessionId() !== sessionId + ) + return + this.resetBackoff(authSessionState.getAuthPayload()) + const remaining = payload.expires_at + ? new Date(payload.expires_at).getTime() - Date.now() + : 0 + // A fast browser clock must not turn short apparent lifetimes into + // refresh storms. Server-side 401 recovery bypasses this throttle. + this.retryAfter = + Date.now() + + (remaining > 0 + ? Math.max( + MIN_SUCCESS_THROTTLE_MS, + Math.min(MAX_SUCCESS_THROTTLE_MS, remaining / 2), + ) + : MAX_SUCCESS_THROTTLE_MS) + }) + .catch(reportFailure) + + let hardTimer: ReturnType + let cancel!: () => void + const hardLimit = new Promise((resolve) => { + cancel = () => { + clearTimeout(hardTimer) + deadline.abort() + requestController.abort() + resolve() + } + // Access-token expiry does not invalidate a rotating refresh. Keep its + // response for the full network deadline, independently of query waits. + // Abort before freeing the slot, even if body reading ignores abort. + hardTimer = setTimeout(() => { + reportFailure(new DeadlineExceededError()) + cancel() + if (this.pending?.softDeadline === softDeadline) this.pending = null + }, REFRESH_REQUEST_TIMEOUT_MS) + }) + const hardDeadline = Promise.race([request, hardLimit]) + const softDeadline = withDeadline( + hardDeadline, + REFRESH_WAIT_TIMEOUT_MS, + () => deadline.abort(), + ).catch(reportFailure) + this.pending = { softDeadline, hardDeadline, sessionId, cancel } + void request.finally(() => { + clearTimeout(hardTimer) + if (this.pending?.softDeadline === softDeadline) this.pending = null + }) + } +} + +// All authenticated requests share refresh coordination for the tab's login. +export const tokenRefresh = new TokenRefreshCoordinator() +authSessionState.onEnd(() => tokenRefresh.syncSession()) + +export const awaitRefresh = (refresh: Promise, signal: AbortSignal) => { + if (signal.aborted) + return Promise.reject(new DOMException("Aborted", "AbortError")) + return new Promise((resolve, reject) => { + const onAbort = () => reject(new DOMException("Aborted", "AbortError")) + signal.addEventListener("abort", onAbort, { once: true }) + void refresh.then( + () => { + signal.removeEventListener("abort", onAbort) + resolve() + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) +} diff --git a/src/utils/questdb/types.ts b/src/utils/questdb/types.ts index 15ed53aff..73c20a5d9 100644 --- a/src/utils/questdb/types.ts +++ b/src/utils/questdb/types.ts @@ -73,7 +73,7 @@ export type RawDmlResult = { export type RawErrorResult = { ddl: undefined dml: undefined - error: "" + error: string position: number query: string } @@ -82,7 +82,7 @@ export type RawNoticeResult = { ddl: undefined dml: undefined error: undefined - notice: "" + notice: string position: undefined query: string columns?: ColumnDefinition[] @@ -109,11 +109,20 @@ export type RawResult = | RawErrorResult | RawNoticeResult -export type ErrorResult = RawErrorResult & { - type: Type.ERROR - status: number +// Local execution metadata shared by the client, notebooks and agent adapters. +export type SessionFailure = { + sessionEnded?: boolean + requestNotSent?: boolean } +export type ErrorResult = Omit & { + type: Type.ERROR + // Local failures and aborted/session-ended requests have no HTTP response. + status?: number + connectionError?: boolean + authRefreshUnavailable?: boolean +} & SessionFailure + export type NoticeResult = RawNoticeResult & { type: Type.NOTICE } @@ -364,6 +373,27 @@ export type Options = { cols?: string src?: string cancellable?: boolean + /** Background work must not extend an idle login; never sent on the wire. */ + refreshAuth?: boolean + /** + * A console-issued catalog, monitoring or display read (schema, catalog + * sources, metrics, telemetry, result pages and charts), not a statement the + * user ran. It is still aborted when the session ends, but never makes the + * session busy or raises the "statements may have executed" warning. + * Never sent on the wire. + */ + background?: boolean +} + +// In-process lifecycle hooks are not SQL wire parameters. +export type QueryLifecycle = { + /** Invoked immediately before the SQL fetch, after any token refresh wait. */ + onRequestStart?: () => void + /** + * Invoked when the query starts waiting for a token refresh: before its first + * fetch, or before retrying a fetch that the server rejected with 401. + */ + onRefreshWait?: (timeoutMs: number) => void } export type Release = { diff --git a/src/utils/telemetryQueries.test.ts b/src/utils/telemetryQueries.test.ts new file mode 100644 index 000000000..b84c40972 --- /dev/null +++ b/src/utils/telemetryQueries.test.ts @@ -0,0 +1,75 @@ +import "../test/stubBrowserGlobals" +import { afterEach, describe, expect, it, vi } from "vitest" +import { authSessionState } from "../modules/OAuth2/authSessionState" +import type { AuthPayload } from "../modules/OAuth2/types" +import { Client } from "./questdb/client" +import { queryTelemetryConfig } from "./telemetryQueries" + +const token = (expiresIn = 300_000): AuthPayload => ({ + access_token: "idle-session", + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + expiresIn).toISOString(), +}) + +afterEach(() => { + authSessionState.endSession() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe("telemetry config queries", () => { + it("uses the non-interactive options shared by startup and settings saves", async () => { + authSessionState.setAuthPayload(token()) + const client = new Client() + const query = vi.spyOn(client, "query") + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + const pending = queryTelemetryConfig(client) + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + expect(query).toHaveBeenCalledWith("telemetry_config limit -1", { + refreshAuth: false, + background: true, + }) + expect(client.hasActiveRequests()).toBe(false) + expect(client.hasSentRequests()).toBe(false) + finish(new Response(JSON.stringify({ columns: [], dataset: [], count: 0 }))) + await expect(pending).resolves.toMatchObject({ type: "dql", count: 0 }) + }) + + it.each(["near expiry", "rejected bearer"])( + "skips %s without refreshing or ending the SSO session", + async (scenario) => { + authSessionState.setAuthPayload( + token(scenario === "near expiry" ? 20_000 : 300_000), + ) + const session = authSessionState.capture() + const payload = authSessionState.getAuthPayload() + const client = new Client() + const refresh = vi.fn() + client.refreshTokenMethod = refresh + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("", { status: 401 }))), + ) + await expect(queryTelemetryConfig(client)).rejects.toMatchObject({ + authRefreshUnavailable: true, + requestNotSent: true, + }) + expect(refresh).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledTimes(scenario === "near expiry" ? 0 : 1) + expect(session.isCurrent()).toBe(true) + expect(authSessionState.getAuthPayload()).toBe(payload) + expect(authSessionState.hasRefreshFailed()).toBe(false) + expect(client.hasActiveRequests()).toBe(false) + }, + ) +}) diff --git a/src/utils/telemetryQueries.ts b/src/utils/telemetryQueries.ts new file mode 100644 index 000000000..ef428dc4f --- /dev/null +++ b/src/utils/telemetryQueries.ts @@ -0,0 +1,17 @@ +import { TelemetryTable } from "../consts" +import type { TelemetryConfigShape } from "../store/Telemetry/types" +import type { Client } from "./questdb/client" +import type { Options } from "./questdb/types" + +// Housekeeping must not renew an idle login or count as user execution. +export const TELEMETRY_QUERY_OPTIONS = { + refreshAuth: false, + background: true, +} as const satisfies Options + +// Shared by startup telemetry and the instance-settings save path. +export const queryTelemetryConfig = (quest: Pick) => + quest.query( + `${TelemetryTable.CONFIG} limit -1`, + TELEMETRY_QUERY_OPTIONS, + ) diff --git a/src/utils/tools/runQuery.test.ts b/src/utils/tools/runQuery.test.ts index 08fd2f2a5..808fbcee4 100644 --- a/src/utils/tools/runQuery.test.ts +++ b/src/utils/tools/runQuery.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest" +import type { Client } from "../questdb/client" import { Type } from "../questdb/types" import { buildRunQueryPayload, @@ -143,6 +144,24 @@ describe("buildRunQueryPayload", () => { }) describe("mapQueryRawToResult", () => { + it("does not report a resolved ErrorResult as DML success", async () => { + const error = { + type: Type.ERROR, + query: "insert into t values (1)", + error: "not sent", + position: -1, + requestNotSent: true, + } + const quest = { + queryRaw: vi.fn(() => ({ promise: Promise.resolve(error), queryId: 1 })), + abort: vi.fn(), + } as unknown as Client + expect(await mapQueryRawToResult(quest, error.query, 100)).toMatchObject({ + type: "error", + error: "not sent", + requestNotSent: true, + }) + }) const stubDqlReturn = () => ({ promise: Promise.resolve({ type: Type.DQL, @@ -196,6 +215,56 @@ describe("mapQueryRawToResult", () => { expect(res).toMatchObject({ type: "notice", message: "hint applied" }) }) + it("preserves the session-ended outcome flag on rejection", async () => { + const error = { + type: Type.ERROR, + error: "Your session has ended. Please sign in again.", + position: -1, + sessionEnded: true, + } + const queryRaw = vi.fn().mockReturnValue({ + promise: Promise.reject(error), + queryId: 1, + }) + + const result = await mapQueryRawToResult( + { queryRaw, abort: vi.fn() } as never, + "INSERT INTO t VALUES (1)", + 100, + ) + + expect(result).toEqual({ + type: "error", + error: error.error, + position: -1, + sessionEnded: true, + }) + }) + + it("preserves not-sent metadata for the agent's outcome classifier", async () => { + const error = { + type: Type.ERROR, + error: "Could not renew your SSO session; the query was not sent.", + position: -1, + requestNotSent: true, + } + const queryRaw = vi + .fn() + .mockReturnValue({ promise: Promise.reject(error), queryId: 1 }) + expect( + await mapQueryRawToResult( + { queryRaw, abort: vi.fn() } as never, + "INSERT INTO t VALUES (1)", + 100, + ), + ).toEqual({ + type: "error", + error: error.error, + position: -1, + requestNotSent: true, + }) + }) + it("sends the requested row limit to /exec using the cancellable queryRaw overload", async () => { const queryRaw = vi.fn().mockReturnValue(stubDqlReturn()) const abort = vi.fn() diff --git a/src/utils/tools/runQuery.ts b/src/utils/tools/runQuery.ts index 33803aea5..322244d4f 100644 --- a/src/utils/tools/runQuery.ts +++ b/src/utils/tools/runQuery.ts @@ -1,5 +1,9 @@ import type { Client } from "../questdb/client" import { Type } from "../questdb/types" +import { + sessionFailureOf, + type SessionFailure, +} from "../../modules/OAuth2/session" export const RUN_QUERY_DEFAULT_LIMIT = 100 export const RUN_QUERY_MAX_LIMIT = 10_000 @@ -15,7 +19,11 @@ export type RunQueryRawResult = notice?: string } | { type: "ddl" | "dml" | "notice"; durationMs?: number; message?: string } - | { type: "error"; error: string; position?: number } + | ({ + type: "error" + error: string + position?: number + } & SessionFailure) // Wire shape is (string | number | boolean | null)[] — array/object cells // (e.g. QuestDB ARRAY columns) get flattened to a JSON string so the agent @@ -107,7 +115,9 @@ export const mapQueryRawToResult = async ( signal.addEventListener("abort", onAbort, { once: true }) cleanup = () => signal.removeEventListener("abort", onAbort) } - const res = await promise + const res = await promise.then((result) => + result.type === Type.ERROR ? Promise.reject(result) : result, + ) const durationMs = Math.round(performance.now() - startedAt) if (res.type === Type.DQL) { return { @@ -118,9 +128,6 @@ export const mapQueryRawToResult = async ( durationMs, } } - if (res.type === Type.DDL || res.type === Type.DML) { - return { type: res.type, durationMs } - } if (res.type === Type.NOTICE) { if (res.columns && res.columns.length > 0) { return { @@ -134,16 +141,20 @@ export const mapQueryRawToResult = async ( } return { type: "notice", durationMs, message: res.notice } } - return { type: "error", error: res.error, position: res.position } + return { type: res.type === Type.DDL ? "ddl" : "dml", durationMs } } catch (e) { if (e && typeof e === "object" && "error" in e) { - const errLike = e as { error?: unknown; position?: unknown } + const errLike = e as { + error?: unknown + position?: unknown + } if (typeof errLike.error === "string") { return { type: "error", error: errLike.error, position: typeof errLike.position === "number" ? errLike.position : -1, + ...sessionFailureOf(e), } } } @@ -151,6 +162,7 @@ export const mapQueryRawToResult = async ( type: "error", error: e instanceof Error ? e.message : "Unknown error", position: -1, + ...sessionFailureOf(e), } } finally { cleanup?.() diff --git a/src/utils/withDeadline.ts b/src/utils/withDeadline.ts new file mode 100644 index 000000000..5c862e848 --- /dev/null +++ b/src/utils/withDeadline.ts @@ -0,0 +1,27 @@ +export class DeadlineExceededError extends Error { + constructor() { + super("Request timed out") + this.name = "DeadlineExceededError" + } +} + +// Bounds both fetching and reading a body, even when the body reader ignores +// cancellation. The caller decides whether timing out also aborts the request. +export const withDeadline = async ( + request: Promise, + timeoutMs: number, + onTimeout: () => void, +): Promise => { + let timeout: ReturnType | undefined + const deadline = new Promise((_, reject) => { + timeout = setTimeout(() => { + onTimeout() + reject(new DeadlineExceededError()) + }, timeoutMs) + }) + try { + return await Promise.race([request, deadline]) + } finally { + clearTimeout(timeout) + } +} diff --git a/yarn.lock b/yarn.lock index 58c333248..98d170a6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2539,6 +2539,7 @@ __metadata: "@types/react-grid-layout": "npm:^2.1.0" "@types/react-highlight-words": "npm:^0.16.7" "@types/react-redux": "npm:7.1.9" + "@types/react-test-renderer": "npm:17.0.9" "@types/react-transition-group": "npm:4.4.0" "@types/styled-components": "npm:5.1.7" "@types/throttle-debounce": "npm:2.1.0" @@ -2599,6 +2600,7 @@ __metadata: react-markdown: "npm:^8.0.7" react-popper: "npm:2.2.3" react-redux: "npm:7.2.0" + react-test-renderer: "npm:17.0.2" react-toastify: "npm:^9.1.3" react-transition-group: "npm:4.4.1" react-virtuoso: "npm:^2.2.6" @@ -3933,6 +3935,15 @@ __metadata: languageName: node linkType: hard +"@types/react-test-renderer@npm:17.0.9": + version: 17.0.9 + resolution: "@types/react-test-renderer@npm:17.0.9" + dependencies: + "@types/react": "npm:^17" + checksum: 10/4d78a7c98cdfedc0d706df873a55534b70deab143b0403d0db1ca91d580a246322a4da4460862dd80d2a3452882cb40f4188efa358bd3468a93699a65ce5b089 + languageName: node + linkType: hard + "@types/react-transition-group@npm:4.4.0": version: 4.4.0 resolution: "@types/react-transition-group@npm:4.4.0" @@ -10382,6 +10393,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.1.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0, react-is@npm:^16.9.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -10389,10 +10407,10 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.1.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 +"react-is@npm:^17.0.2": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 languageName: node linkType: hard @@ -10511,6 +10529,18 @@ __metadata: languageName: node linkType: hard +"react-shallow-renderer@npm:^16.13.1": + version: 16.15.0 + resolution: "react-shallow-renderer@npm:16.15.0" + dependencies: + object-assign: "npm:^4.1.1" + react-is: "npm:^16.12.0 || ^17.0.0 || ^18.0.0" + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 10/06457fe5bcaa44aeca998905b6849304742ea1cc2d3841e4a0964c745ff392bc4dec07f8c779f317faacce3a0bf6f84e15020ac0fa81adb931067dbb0baf707b + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": version: 2.2.3 resolution: "react-style-singleton@npm:2.2.3" @@ -10527,6 +10557,20 @@ __metadata: languageName: node linkType: hard +"react-test-renderer@npm:17.0.2": + version: 17.0.2 + resolution: "react-test-renderer@npm:17.0.2" + dependencies: + object-assign: "npm:^4.1.1" + react-is: "npm:^17.0.2" + react-shallow-renderer: "npm:^16.13.1" + scheduler: "npm:^0.20.2" + peerDependencies: + react: 17.0.2 + checksum: 10/8137f061880c296b0e8346a487370caf4f1da522a5d19edb00ae9b5834a39198e4e60adaa8e9426cdd7e123bdcb0bbd741abcf8832919c4c1a2234501db2b906 + languageName: node + linkType: hard + "react-toastify@npm:^9.1.3": version: 9.1.3 resolution: "react-toastify@npm:9.1.3" From 58add5e2cfb1e98b2de33d189b9ec6735307eb0d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 20 Oct 2026 00:35:50 +0100 Subject: [PATCH 02/31] update submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index 117720894..e4da5b454 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 11772089470d893ad904af4ce6a233601ffbc221 +Subproject commit e4da5b4543392a88c41ad6da6aeb7c0f6777c237 From 05335a00dcf80dc2d067456bce2190436dc336c2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 20 Oct 2026 13:52:53 +0100 Subject: [PATCH 03/31] fix: keep uploads, the schema tree and notebook results intact when the session ends - Let uploads and settings saves that are already being sent finish when the session ends, instead of aborting them mid-stream: an aborted CSV upload can leave a partially imported table. Their results are still rejected as session-ended and no longer count as the next login's activity. Queries, and writes still waiting for renewal, are cancelled. - Show a failed SSO renewal in the Schema panel and reload it once a request succeeds, instead of leaving the tree blank. - Supersede notebook cell runs, auto-refresh rounds and headless agent runs before session end aborts their requests. Their aborted results no longer overwrite the last good snapshot, save refresh errors, delete chart snapshots or record a possibly committed write as a failed run. - Cover the session-end client behaviour, Schema recovery, notebook session end and run_query's unverified classification, and add an e2e test for renewing and resending an editor statement once after a 401. --- e2e/tests/auth/auth.spec.js | 41 ++++ src/modules/OAuth2/authSessionState.ts | 4 +- .../OAuth2/views/refreshFailedWarning.tsx | 2 +- src/providers/AuthProvider.test.tsx | 22 +- src/providers/AuthProvider.tsx | 5 +- .../cellRefresh/cellRefreshEngine.test.ts | 86 +++++++ .../Notebook/cellRefresh/cellRefreshEngine.ts | 9 + .../Editor/Notebook/useCellExecution.test.ts | 85 ++++++- .../Editor/Notebook/useCellExecution.ts | 16 +- src/scenes/Schema/index.test.tsx | 222 ++++++++++++++++++ src/scenes/Schema/index.tsx | 18 +- .../notebooks/notebookDexieController.test.ts | 29 +++ src/utils/notebooks/notebookHeadlessRun.ts | 13 + src/utils/questdb/client.nonSqlAuth.test.ts | 121 ++++++++++ src/utils/questdb/client.ts | 15 ++ src/utils/questdb/queryClient.ts | 2 +- src/utils/tools/dispatch.test.ts | 88 ++++++- 17 files changed, 756 insertions(+), 22 deletions(-) create mode 100644 src/scenes/Schema/index.test.tsx diff --git a/e2e/tests/auth/auth.spec.js b/e2e/tests/auth/auth.spec.js index e2d344635..d92500b8f 100644 --- a/e2e/tests/auth/auth.spec.js +++ b/e2e/tests/auth/auth.spec.js @@ -743,6 +743,47 @@ describe("Auth - OIDC ID token", () => { }) } + it("renews and resends an editor statement exactly once after the server rejects its bearer", () => { + // The server rejects a bearer the console still considers valid (revoked, + // or the browser clock is behind). A 401 executed no SQL, so the console + // renews and resends the statement once, without signing out. + const initial = idToken("initial", 3600) + const renewed = idToken("renewed", 3600) + signIn({ + idTokens: { authorization_code: initial, refresh_token: renewed }, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.wait("@authCheck") + cy.getEditor().should("be.visible") + const sentWith = [] + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + (req) => { + sentWith.push(req.headers.authorization) + req.reply( + req.headers.authorization === `Bearer ${initial}` + ? { statusCode: 401, body: "Unauthorized" } + : { statusCode: 200, body: queryResult }, + ) + }, + ).as("statement") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@statement") + cy.wait("@statement") + cy.getByDataHook("success-notification").should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.wrap(sentWith).should("deep.equal", [ + `Bearer ${initial}`, + `Bearer ${renewed}`, + ]) + }) + it("clears the remembered identity before the redirect guard forces sign-in", () => { // Unknown expiry avoids a renewal on 401: this test targets the redirect // guard's forced-login path, not refresh recovery or its connection events. diff --git a/src/modules/OAuth2/authSessionState.ts b/src/modules/OAuth2/authSessionState.ts index 36e06701b..a2919fad3 100644 --- a/src/modules/OAuth2/authSessionState.ts +++ b/src/modules/OAuth2/authSessionState.ts @@ -18,7 +18,9 @@ class AuthSessionState { return { isCurrent: () => this.sessionId === sessionId } } - // Only already-accepted data may be flushed here, never new network work. + // Runs before onEnd listeners abort this session's requests. Supersede + // in-flight work here so its aborted results are not recorded, or flush + // already-accepted data. Never start new network work. beforeEnd(listener: () => void) { this.beforeEndListeners.add(listener) return () => { diff --git a/src/modules/OAuth2/views/refreshFailedWarning.tsx b/src/modules/OAuth2/views/refreshFailedWarning.tsx index 8ddedea09..520d84166 100644 --- a/src/modules/OAuth2/views/refreshFailedWarning.tsx +++ b/src/modules/OAuth2/views/refreshFailedWarning.tsx @@ -39,7 +39,7 @@ export const RefreshFailedWarning = ({ : `Could not renew your SSO session. Your current token expires at ${new Date(expiry).toLocaleTimeString()}.`} - Signing in again ends this session and interrupts running requests. + Signing in again ends this session and interrupts running queries. Statements may have executed; verify their outcomes before retrying.