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..f5e4b2f 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,9 +59,24 @@ 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}" + # 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. 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") @@ -72,10 +92,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..928a7e2 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,7 +209,42 @@ 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') { + 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 = ''; + } + + function clearCertWarn() { + certWarn.replaceChildren(); + certWarn.style.display = 'none'; + } + 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 68835fc..f779cf1 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -110,6 +110,71 @@ 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('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, + { 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); }); });