Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<accept-invalid-certificate>1</accept-invalid-certificate>`
(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://<host>:8443`).
Expand Down
27 changes: 24 additions & 3 deletions build/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<accept-invalid-certificate>1</accept-invalid-certificate>`
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
Expand Down Expand Up @@ -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 <http_port> to override (e.g. 443 for a proxy that fronts the HTTP
# interface there with no gateway).
# Don't double-append when <hostname> 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")
Expand All @@ -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()


Expand Down
9 changes: 6 additions & 3 deletions src/net/oauth-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand All @@ -82,6 +84,7 @@ function normalizeHost(h) {
user: e.user || '',
password: e.password || '',
idp: e.idp || '',
insecure: !!e.insecure,
};
}

Expand Down
20 changes: 20 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
53 changes: 50 additions & 3 deletions src/ui/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,19 +189,62 @@ 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;
advOpen = true; advField.style.display = ''; advChev.style.transform = 'rotate(0deg)';
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 {
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/login.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) });
Expand Down
18 changes: 15 additions & 3 deletions tests/unit/oauth-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Loading