From 6e75fe5aeb6fdc88a90de780b827f8eb866c4d64 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 12:03:57 +0200 Subject: [PATCH 1/3] feat(local): support accept-invalid-certificate saved connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clickhouse-client connections carrying 1 now surface a cert-trust step in the login picker. A browser can't bypass TLS validation from fetch(), so the SPA can't honour the flag on its own — instead it walks the user through trusting the cert once. - build/local.py: read accept-invalid-certificate; emit `insecure` on basic and oauth host descriptors in config.json. - src/net/oauth-config.js: carry `insecure` through normalizeHost (it was silently dropped — the picker never saw the flag). - src/ui/login.js: insecure host → show a cert-trust panel (open-cluster link + guidance that the post-handshake redirect to an auth gateway/login page is expected and unrelated). For oauth, hold the SSO redirect behind a Continue button so the cert is trusted before any post-login query hits the cluster. - styles.css: panel styling. README + docstring: document the flow. - tests: login picker (basic/oauth/clear) + oauth-config insecure pass-through. Verified end-to-end via `python3 build/local.py` against the `audit` support connection (support-a.tenant-a.dev.altinity.cloud): the cluster root 302-redirects to acm.altinity.cloud/login after the TLS handshake, which is the "wrong redirect" the panel now explains. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- README.md | 9 ++++++ build/local.py | 15 ++++++++-- src/net/oauth-config.js | 9 ++++-- src/styles.css | 20 +++++++++++++ src/ui/login.js | 50 +++++++++++++++++++++++++++++-- tests/unit/login.test.js | 52 +++++++++++++++++++++++++++++++++ tests/unit/oauth-config.test.js | 18 ++++++++++-- 7 files changed, 162 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c45d400..5899d5e 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,15 @@ offers them as a **Saved connection** dropdown on the login screen: - A connection carrying clickhouse-client's OAuth keys (`oauth-url`, `oauth-client-id`, `oauth-audience`) → an OAuth sign-in against that cluster. +A connection with `1` +(a self-signed or wrong-host TLS cert, common on dev tenants) is flagged in the +picker. The browser refuses to `fetch()` such a host and JavaScript can't +override that, so when you select it the login screen surfaces a one-time step: +open the cluster in a new tab and accept its certificate, after which the SPA can +reach it for the rest of the browser session. For an OAuth connection the sign-in +redirect is held behind a **Continue** button so the cert is trusted before any +post-login query hits the cluster. + You can also ignore the picker and type a host/user/password by hand (host: include the scheme, e.g. `http://localhost:8123`; a bare host defaults to `https://:8443`). diff --git a/build/local.py b/build/local.py index 7771291..4f5df38 100644 --- a/build/local.py +++ b/build/local.py @@ -13,6 +13,11 @@ `oauth-client-id`, optional `oauth-client-secret` for a Web client like Google, `oauth-audience`) → an OAuth sign-in against that cluster. + A connection with `1` + is flagged `insecure` in config.json. The browser can't skip TLS validation + from fetch(), so the login screen walks the user through trusting the cert + once (opening the cluster in a tab) before connecting. + npm run local # build + serve, then open http://localhost:8900/sql For OAuth connections you also register `http://localhost:8900/sql` as a redirect @@ -54,6 +59,11 @@ def build_config(): if not name or not hostname: continue secure = _text(conn, "secure").lower() in ("1", "true", "yes") + # A self-signed / wrong-host TLS cert. The browser can't bypass cert + # validation from fetch(), so the SPA can't honour this on its own — it + # flags the connection and walks the user through trusting the cert once + # (see populateHosts in src/ui/login.js). + insecure = _text(conn, "accept-invalid-certificate", "accept_invalid_certificate").lower() in ("1", "true", "yes") http_port = _text(conn, "http_port", "http-port") scheme = "https" if secure else "http" url = f"{scheme}://{hostname}:{http_port}" if http_port else f"{scheme}://{hostname}" @@ -72,10 +82,11 @@ def build_config(): "bearer": "access_token" if oauth_aud else "id_token", }) seen.add(name) - hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name}) + hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, "insecure": insecure}) else: hosts.append({"label": name, "url": url, "auth": "basic", - "user": _text(conn, "user"), "password": _text(conn, "password")}) + "user": _text(conn, "user"), "password": _text(conn, "password"), + "insecure": insecure}) return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode() diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js index 31b1677..f7d8c5b 100644 --- a/src/net/oauth-config.js +++ b/src/net/oauth-config.js @@ -69,9 +69,11 @@ function normalizeEntry(e) { /** * Map one raw `hosts[]` entry to a saved-connection descriptor for the login - * picker: `{ label, url, auth, user, password, idp }`. `auth` is 'oauth' (sign in - * via the named `idp`, querying `url` cross-origin) or 'basic' (prefill the - * credentials form with `url`/`user`/`password`). + * picker: `{ label, url, auth, user, password, idp, insecure }`. `auth` is 'oauth' + * (sign in via the named `idp`, querying `url` cross-origin) or 'basic' (prefill + * the credentials form with `url`/`user`/`password`). `insecure` flags an + * accept-invalid-certificate host — the browser can't reach it until the user + * trusts the cert, so the picker surfaces that step (see renderLogin). */ function normalizeHost(h) { const e = h || {}; @@ -82,6 +84,7 @@ function normalizeHost(h) { user: e.user || '', password: e.password || '', idp: e.idp || '', + insecure: !!e.insecure, }; } diff --git a/src/styles.css b/src/styles.css index 3f33b3e..1da0562 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1421,3 +1421,23 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } background: none; border: none; color: var(--error-fg); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px; } + +/* Insecure (accept-invalid-certificate) saved connection: trust-the-cert step + shown under the picker. */ +.login-cert-warn { + margin-top: 8px; padding: 10px 12px; + display: flex; flex-direction: column; gap: 8px; + background: var(--error-bg); border: 1px solid var(--error-bd); + border-radius: 8px; +} +.login-cert-msg { + display: flex; gap: 8px; align-items: flex-start; + font-size: 11.5px; line-height: 1.5; color: var(--fg); +} +.login-cert-msg svg { flex: none; margin-top: 1px; color: var(--error-fg); } +.login-cert-link { + display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; + font-size: 11.5px; color: var(--accent); text-decoration: none; +} +.login-cert-link:hover { text-decoration: underline; } +.login-cert-go { margin-top: 2px; } diff --git a/src/ui/login.js b/src/ui/login.js index 56bd927..e14b0b8 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -102,9 +102,13 @@ export function renderLogin(app, errorMsg) { // --- saved-connection picker (populated async; shown only when config lists hosts) --- let pickHosts = []; const hostPicker = h('select', { class: 'login-picker mono', onchange: onPickHost }); + // Shown only for an `insecure` (accept-invalid-certificate) connection — the + // browser can't be reached until its cert is trusted (see showCertWarn). + const certWarn = h('div', { class: 'login-cert-warn', style: { display: 'none' } }); const pickerSection = h('div', { class: 'login-field login-picker-field', style: { display: 'none' } }, h('label', { class: 'login-lbl' }, 'Saved connection'), - hostPicker); + hostPicker, + certWarn); // Footer tag adapts to which methods are available (set by applyChrome once // the IdP list / basic_login flag resolve). The brand block is heading enough, @@ -185,11 +189,19 @@ export function renderLogin(app, errorMsg) { } // Pick a saved connection: a basic one prefills the credentials form (+ reveals - // the host); an oauth one starts the SSO flow against that cluster. + // the host); an oauth one starts the SSO flow against that cluster. An + // `insecure` (accept-invalid-certificate) connection first surfaces the + // cert-trust step — and, for oauth, holds the redirect behind a Continue button + // so the cert is trusted before any post-login query reaches the cluster. function onPickHost() { + clearCertWarn(); if (hostPicker.value === '') return; const hh = pickHosts[Number(hostPicker.value)]; - if (hh.auth === 'oauth') { pickOAuth(hh); return; } + if (hh.insecure) showCertWarn(hh); + if (hh.auth === 'oauth') { + if (!hh.insecure) pickOAuth(hh); // insecure → wait for the warning's Continue button + return; + } hostInput.value = hh.url; userInput.value = hh.user; passInput.value = hh.password; @@ -197,6 +209,38 @@ export function renderLogin(app, errorMsg) { update(); } + // The browser refuses to fetch() a host with an untrusted TLS cert and JS can't + // override that — so for an `insecure` connection we point the user at the + // cluster to accept the cert once (per browser session). For oauth, the redirect + // is gated behind Continue so the post-login queries don't hit an untrusted host. + function showCertWarn(hh) { + const kids = [ + h('div', { class: 'login-cert-msg' }, Icon.shield(), + h('span', null, 'This connection uses a self-signed or otherwise invalid TLS certificate. ' + + 'Your browser blocks it until you open it once and click through the warning to trust the cert.')), + h('a', { class: 'login-cert-link mono', href: hh.url, target: '_blank', rel: 'noopener noreferrer' }, + h('span', null, 'Open ' + hh.label + ' to accept its certificate'), Icon.arrow()), + // The opened host often 302-redirects (an auth gateway → its own login, a + // status page, etc.) once the handshake succeeds — that lands you somewhere + // unrelated, but the cert is already trusted by then. Say so, so the redirect + // doesn't read as a failure: close that tab and come back here. + h('div', { class: 'login-hint' }, + 'A certificate prompt should appear — choose proceed/accept. Any login or status ' + + 'page it then redirects to is expected and unrelated; just close that tab, return here, and connect.'), + ]; + if (hh.auth === 'oauth') { + kids.push(h('button', { class: 'login-btn btn-primary login-cert-go', onclick: () => pickOAuth(hh) }, + Icon.shield(), h('span', null, 'Continue — sign in'))); + } + certWarn.replaceChildren(...kids); + certWarn.style.display = ''; + } + + function clearCertWarn() { + certWarn.replaceChildren(); + certWarn.style.display = 'none'; + } + async function pickOAuth(hh) { busy = 'sso'; hostPicker.disabled = true; diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index 68835fc..fc4fd3d 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -110,6 +110,58 @@ describe('renderLogin — host picker', () => { }); }); +describe('renderLogin — insecure (accept-invalid-certificate) hosts', () => { + const insecureHosts = [ + { label: 'audit', url: 'https://support-a.tenant-a.dev.altinity.cloud', auth: 'basic', user: 'mcp', password: 'pw', idp: '', insecure: true }, + { label: 'audit-oauth', url: 'https://support-a.tenant-a.dev.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google', insecure: true }, + ]; + const withInsecure = (over = {}) => appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: insecureHosts }), ...over }); + + it('basic insecure host: prefills the form and shows the cert-trust step with an open-cluster link (no Continue button)', async () => { + const login = vi.fn(); + const app = withInsecure({ actions: { login } }); + renderLogin(app); await tick(); + selectHost(app.root, '0'); + const [user, , host] = app.root.querySelectorAll('.login-input'); + expect([host.value, user.value]).toEqual(['https://support-a.tenant-a.dev.altinity.cloud', 'mcp']); + const warn = app.root.querySelector('.login-cert-warn'); + expect(warn.style.display).toBe(''); + const link = warn.querySelector('.login-cert-link'); + expect(link.getAttribute('href')).toBe('https://support-a.tenant-a.dev.altinity.cloud'); + expect(link.textContent).toContain('Open audit'); + expect(warn.querySelector('.login-cert-go')).toBeNull(); // basic: no SSO redirect to gate + expect(login).not.toHaveBeenCalled(); + }); + + it('oauth insecure host: shows the cert step + Continue and does NOT auto-redirect until Continue is clicked', async () => { + const login = vi.fn(async () => {}); + const app = withInsecure({ actions: { login } }); + renderLogin(app); await tick(); + selectHost(app.root, '1'); + expect(login).not.toHaveBeenCalled(); // gated behind the cert-trust step + const go = app.root.querySelector('.login-cert-go'); + expect(go).not.toBeNull(); + click(go); + expect(login).toHaveBeenCalledWith('google', 'https://support-a.tenant-a.dev.altinity.cloud'); + }); + + it('clears the cert step when switching to the placeholder or a normal connection', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [ + ...insecureHosts, + { label: 'plain', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '' }, + ] }) }); + renderLogin(app); await tick(); + selectHost(app.root, '0'); + expect(app.root.querySelector('.login-cert-warn').style.display).toBe(''); + selectHost(app.root, ''); + expect(app.root.querySelector('.login-cert-warn').style.display).toBe('none'); + selectHost(app.root, '0'); + expect(app.root.querySelector('.login-cert-warn').style.display).toBe(''); + selectHost(app.root, '2'); // a normal (secure) basic connection + expect(app.root.querySelector('.login-cert-warn').style.display).toBe('none'); + }); +}); + describe('renderLogin — SSO section', () => { it('no IdPs → no SSO button, divider hidden, credentials present', async () => { const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true }) }); diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js index ad0c54c..09597c3 100644 --- a/tests/unit/oauth-config.test.js +++ b/tests/unit/oauth-config.test.js @@ -127,13 +127,25 @@ describe('loadConfigDoc hosts', () => { { label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', idp: 'google' }, ], }); - expect(hosts[0]).toEqual({ label: 'demo', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '' }); - expect(hosts[1]).toEqual({ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google' }); + expect(hosts[0]).toEqual({ label: 'demo', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '', insecure: false }); + expect(hosts[1]).toEqual({ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google', insecure: false }); }); it('falls back the label to the url and defaults missing fields', async () => { const { hosts } = await load({ idps: [], hosts: [{ url: 'http://h:8123' }] }); - expect(hosts[0]).toEqual({ label: 'http://h:8123', url: 'http://h:8123', auth: 'basic', user: '', password: '', idp: '' }); + expect(hosts[0]).toEqual({ label: 'http://h:8123', url: 'http://h:8123', auth: 'basic', user: '', password: '', idp: '', insecure: false }); + }); + + it('carries the accept-invalid-certificate flag through as `insecure`', async () => { + const { hosts } = await load({ + idps: [], + hosts: [ + { label: 'audit', url: 'https://support-a.dev.altinity.cloud', user: 'mcp', insecure: true }, + { label: 'plain', url: 'http://localhost:8123' }, + ], + }); + expect(hosts[0].insecure).toBe(true); + expect(hosts[1].insecure).toBe(false); }); }); From 5771ca0c2965a511556c02f075208d823be1fc4f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 12:17:30 +0200 Subject: [PATCH 2/3] fix(local): target ClickHouse HTTP ports (8443/8123), not 443/80 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saved connection without an explicit http_port built a portless URL, so secure hosts resolved to :443. On managed Altinity Cloud endpoints :443 is an auth gateway (a browser GET 302s to acm.altinity.cloud/login) — the SPA never reached ClickHouse, and the :443 cert is valid so "accept the certificate" trusted nothing relevant. The real HTTPS interface (with the self-signed cert) is :8443. Default secure→8443, plain→8123 (ClickHouse's own HTTP-interface defaults), mirroring the SPA's resolveTarget for a bare host. Verified all configured clusters answer /ping on 8443; support-a only works there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- build/local.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build/local.py b/build/local.py index 4f5df38..03f13a0 100644 --- a/build/local.py +++ b/build/local.py @@ -66,7 +66,12 @@ def build_config(): insecure = _text(conn, "accept-invalid-certificate", "accept_invalid_certificate").lower() in ("1", "true", "yes") http_port = _text(conn, "http_port", "http-port") scheme = "https" if secure else "http" - url = f"{scheme}://{hostname}:{http_port}" if http_port else f"{scheme}://{hostname}" + # Default to ClickHouse's HTTP-interface ports (8443 TLS / 8123 plain), NOT + # 443/80 — mirrors the SPA's resolveTarget for a bare host. Managed endpoints + # often park an auth gateway on 443 (a browser GET 302s to an SSO login), so + # 443 wouldn't reach ClickHouse; 8443 is the direct HTTPS interface. + port = http_port or ("8443" if secure else "8123") + url = f"{scheme}://{hostname}:{port}" oauth_url = _text(conn, "oauth-url", "oauth_url") oauth_client = _text(conn, "oauth-client-id", "oauth_client_id") oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret") From 3e3e607491719e7b4bc1bd9b9f2a391c89e5611f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 12:24:34 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(login):=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?pickOAuth=20re-entrancy=20+=20local.py=20port=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login.js: pickOAuth now guards `if (busy) return` (it became reachable from the cert panel's Continue button, not just the picker's onchange, so a fast double-click could race two OAuth flows and corrupt the PKCE verifier/state). The Continue button also disables itself on click, mirroring doSso. (#1) - build/local.py: don't append a default port when already carries one (host:port → was becoming host:port:8443). (#2) - build/local.py: document that an explicit overrides the 8443/8123 default (e.g. 443 for a proxy that fronts the HTTP interface with no gateway). (#3) - test: cover the Continue double-submit guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- build/local.py | 11 ++++++++--- src/ui/login.js | 7 +++++-- tests/unit/login.test.js | 13 +++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/build/local.py b/build/local.py index 03f13a0..f5e4b2f 100644 --- a/build/local.py +++ b/build/local.py @@ -69,9 +69,14 @@ def build_config(): # Default to ClickHouse's HTTP-interface ports (8443 TLS / 8123 plain), NOT # 443/80 — mirrors the SPA's resolveTarget for a bare host. Managed endpoints # often park an auth gateway on 443 (a browser GET 302s to an SSO login), so - # 443 wouldn't reach ClickHouse; 8443 is the direct HTTPS interface. - port = http_port or ("8443" if secure else "8123") - url = f"{scheme}://{hostname}:{port}" + # 443 wouldn't reach ClickHouse; 8443 is the direct HTTPS interface. Set an + # explicit to override (e.g. 443 for a proxy that fronts the HTTP + # interface there with no gateway). + # Don't double-append when already carries a port (clickhouse-client + # accepts host:port), else 'h:9000' would become 'h:9000:8443'. + tail = hostname.rsplit(":", 1) + has_port = len(tail) == 2 and tail[1].isdigit() + url = f"{scheme}://{hostname}" if has_port else f"{scheme}://{hostname}:{http_port or ('8443' if secure else '8123')}" oauth_url = _text(conn, "oauth-url", "oauth_url") oauth_client = _text(conn, "oauth-client-id", "oauth_client_id") oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret") diff --git a/src/ui/login.js b/src/ui/login.js index e14b0b8..928a7e2 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -229,8 +229,10 @@ export function renderLogin(app, errorMsg) { + 'page it then redirects to is expected and unrelated; just close that tab, return here, and connect.'), ]; if (hh.auth === 'oauth') { - kids.push(h('button', { class: 'login-btn btn-primary login-cert-go', onclick: () => pickOAuth(hh) }, - Icon.shield(), h('span', null, 'Continue — sign in'))); + const go = h('button', { class: 'login-btn btn-primary login-cert-go', + onclick: () => { go.disabled = true; pickOAuth(hh); } }, + Icon.shield(), h('span', null, 'Continue — sign in')); + kids.push(go); } certWarn.replaceChildren(...kids); certWarn.style.display = ''; @@ -242,6 +244,7 @@ export function renderLogin(app, errorMsg) { } async function pickOAuth(hh) { + if (busy) return; // reachable from the cert panel's Continue button — guard re-entry like doSso busy = 'sso'; hostPicker.disabled = true; try { diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index fc4fd3d..f779cf1 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -145,6 +145,19 @@ describe('renderLogin — insecure (accept-invalid-certificate) hosts', () => { expect(login).toHaveBeenCalledWith('google', 'https://support-a.tenant-a.dev.altinity.cloud'); }); + it('oauth insecure host: Continue guards against double-submit (disables itself + busy gate)', async () => { + // login stays pending so `busy` is still 'sso' on the second click. + const login = vi.fn(() => new Promise(() => {})); + const app = withInsecure({ actions: { login } }); + renderLogin(app); await tick(); + selectHost(app.root, '1'); + const go = app.root.querySelector('.login-cert-go'); + click(go); + expect(go.disabled).toBe(true); + click(go); // re-entry blocked by the busy guard in pickOAuth + expect(login).toHaveBeenCalledTimes(1); + }); + it('clears the cert step when switching to the placeholder or a normal connection', async () => { const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [ ...insecureHosts,