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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ entirely; the SSO buttons disappear and only the username/password form shows
Credentials authenticate against the **serving host** by default. The login
screen's **Advanced → Server address** field can aim the credential path at a
**different** `host:port` (a bare host defaults to `https://…:8443`); SSO always
stays on the serving host. The same-origin path needs no extra setup, but a
stays on the serving host. You can pre-fill that field with a **`?host=` URL
param** — e.g. `…/sql?host=other.example:9000` opens Advanced with the address
filled in and **disables the SSO buttons** (SSO can only target the serving
host), so the link drops you straight into credential sign-in for that server.
The same-origin path needs no extra setup, but a
**cross-origin** target has two requirements:

- **The SPA's own CSP.** `deploy/http_handlers.xml` sets `connect-src 'self'`
Expand Down
23 changes: 22 additions & 1 deletion src/net/oauth-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,35 @@ function idpHost(issuer) {
}
}

// Friendly provider names so the sign-in button reads "Continue with GitHub"
// rather than "Continue with altinity.auth0.com" when a config sets no `label`.
const CONNECTION_NAMES = { github: 'GitHub', google: 'Google', 'google-oauth2': 'Google', gitlab: 'GitLab' };
const ISSUER_NAMES = { 'accounts.google.com': 'Google', 'login.microsoftonline.com': 'Microsoft', 'github.com': 'GitHub' };

/**
* Button label for an IdP. Prefers an explicit `label`; else derives one from an
* Auth0-style `authorize_params.connection` (e.g. github → "GitHub"), then a
* known issuer host (accounts.google.com → "Google"); finally the issuer host.
*/
function idpLabel(e) {
if (e.label) return e.label;
const conn = e.authorize_params && e.authorize_params.connection;
if (conn) {
const c = String(conn).toLowerCase();
return CONNECTION_NAMES[c] || (c.charAt(0).toUpperCase() + c.slice(1));
}
const host = idpHost(e.issuer);
return ISSUER_NAMES[host] || host;
}

/** Map one raw config.json entry to the canonical (pre-discovery) IdP descriptor. */
function normalizeEntry(e) {
if (!e || !e.issuer || !e.client_id) {
throw new Error('config.json IdP missing issuer or client_id');
}
return {
id: e.id || idpHost(e.issuer),
label: e.label || idpHost(e.issuer),
label: idpLabel(e),
issuer: e.issuer,
clientId: e.client_id,
clientSecret: e.client_secret || '',
Expand Down
3 changes: 3 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export function createApp(env = {}) {
? originHost(chCtx.origin) || 'clickhouse'
: loc.host || 'clickhouse');
app.activeTab = () => activeTab(app.state);
// A `?host=` query param pre-fills the credential server address on the login
// screen (and disables SSO, which only targets the serving host).
app.hostHint = new URLSearchParams(loc.search || '').get('host') || '';
app.isSignedIn = () => (app.authMode === 'basic'
? !!basicCreds()
: !!app.token && !isTokenExpired(app.token, 0));
Expand Down
65 changes: 44 additions & 21 deletions src/ui/login.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// The sign-in screen. Two auth paths, encoded directly in the UI:
// • SSO — the existing OAuth flow, bound to the serving host. One button per
// configured IdP (a single IdP shows one "Continue with SSO"). Hidden when
// no IdP is configured.
// configured IdP, labelled with the IdP ("Continue with Google"). Hidden
// when no IdP is configured.
// • Credentials — a ClickHouse username/password (HTTP Basic), optionally
// against another host via the "Advanced" disclosure. Hidden when the
// deployment sets `basic_login: false`.
// When both username and password are non-empty the UI flips: Connect becomes
// the primary button and SSO demotes to a secondary outline — visually encoding
// "credentials are used instead of SSO". A live "Target" row always resolves the
// combined state (effective host + as <user> / via SSO).
// When credentials are in play (both fields filled, or a custom host is set —
// including via a `?host=` URL param, which pre-fills Advanced) the UI favours
// credentials: Connect becomes primary and the SSO buttons demote, and disable
// entirely for a custom host (SSO can only target the serving host). A live
// "Target" row resolves the combined state (effective host + as <user> / via SSO).

import { h } from './dom.js';
import { Icon } from './icons.js';
Expand All @@ -25,7 +26,11 @@ export function renderLogin(app, errorMsg) {
const cur = app.host();
let busy = null; // 'sso' | 'creds' — guards against double-submit
let showPw = false;
let advOpen = false;
// A `?host=` URL param pre-fills the credential server address. A non-empty
// host means credential-only (SSO can only target the serving host), so
// Advanced opens and the SSO buttons disable.
const hostHint = app.hostHint || '';
let advOpen = !!hostHint;
let ssoBtns = [];

const hasCreds = () => userInput.value.trim().length > 0 && passInput.value.length > 0;
Expand All @@ -37,7 +42,7 @@ export function renderLogin(app, errorMsg) {
});
const userInput = fld({ placeholder: 'default' });
const passInput = fld({ type: 'password', placeholder: '••••••••' });
const hostInput = fld({ placeholder: cur + ':8443' });
const hostInput = fld({ placeholder: cur + ':8443', value: hostHint });

const eyeBtn = h('button', {
class: 'login-eye', type: 'button', tabindex: '-1', title: 'Show password',
Expand Down Expand Up @@ -65,7 +70,8 @@ export function renderLogin(app, errorMsg) {
advChev.style.transform = advOpen ? 'rotate(0deg)' : 'rotate(-90deg)';
},
}, advChev, h('span', null, 'Advanced — connect to another server'));
advChev.style.transform = 'rotate(-90deg)';
advChev.style.transform = advOpen ? 'rotate(0deg)' : 'rotate(-90deg)';
if (advOpen) advField.style.display = '';

// --- connect button + live target row ---
const connectBtn = h('button', { class: 'login-btn btn-ghost', disabled: true, onclick: doConnect },
Expand Down Expand Up @@ -93,14 +99,17 @@ export function renderLogin(app, errorMsg) {
h('span', { style: { flex: '1' } }),
targetAsEl));

// 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,
// so there's no separate "Sign in" title or subtitle.
const footVer = h('span', { class: 'mono login-foot-ver' }, 'OAuth · credentials');

const card = h('div', { class: 'login-card login-card-wide' },
h('div', { class: 'login-brand' },
h('div', { class: 'login-logo' }, 'A'),
h('div', { class: 'login-brand-text' },
h('div', { class: 'login-brand-name' }, 'Altinity SQL Browser'),
h('div', { class: 'login-brand-sub mono' }, 'ClickHouse query console'))),
h('div', { class: 'login-h1' }, 'Sign in'),
h('div', { class: 'login-sub' }, 'Use single sign-on for this server, or connect with ClickHouse credentials.'),
ssoSection,
credSection,
errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null,
Expand All @@ -110,7 +119,7 @@ export function renderLogin(app, errorMsg) {
target: '_blank', rel: 'noopener noreferrer',
}, Icon.github(), h('span', null, 'Source')),
h('span', { style: { flex: '1' } }),
h('span', { class: 'mono login-foot-ver' }, 'OAuth · credentials')));
footVer));

app.root.replaceChildren(h('div', { class: 'login-screen' }, card));
update();
Expand All @@ -119,11 +128,19 @@ export function renderLogin(app, errorMsg) {
// sections are shown. On failure keep credentials visible (fail-open — OAuth
// can't work without config anyway) and show no SSO.
app.loadIdps().then(({ idps, basicLogin }) => {
if (basicLogin === false) credSection.remove();
const credsShown = basicLogin !== false;
if (!credsShown) credSection.remove();
populateSso(idps);
divider.style.display = (ssoBtns.length && basicLogin !== false) ? '' : 'none';
applyChrome(ssoBtns.length > 0, credsShown);
update();
}).catch(() => { /* no config → credentials only */ });
}).catch(() => applyChrome(false, true)); // no config → credentials only

// Reconcile subtitle, footer tag, and the SSO/credentials divider with which
// sign-in methods are actually offered.
function applyChrome(hasSso, credsShown) {
divider.style.display = (hasSso && credsShown) ? '' : 'none';
footVer.textContent = [hasSso && 'OAuth', credsShown && 'credentials'].filter(Boolean).join(' · ') || '—';
}

function populateSso(idps) {
ssoBtns = [];
Expand All @@ -134,9 +151,9 @@ export function renderLogin(app, errorMsg) {
ssoBtns.push(b);
return b;
};
const btns = idps.length === 1
? [mk(idps[0].id, 'Continue with SSO')]
: idps.map((i) => mk(i.id, 'Continue with ' + i.label));
// Always label the button with the IdP — "Continue with Google" reads
// better than a generic "SSO", and disambiguates when several are configured.
const btns = idps.map((i) => mk(i.id, 'Continue with ' + i.label));
ssoSection.replaceChildren(
...btns,
h('div', { class: 'login-sso-note' },
Expand All @@ -147,15 +164,21 @@ export function renderLogin(app, errorMsg) {
// with the field values — updated in place so focus/caret are preserved.
function update() {
const has = hasCreds();
// A custom server address means credential-only — SSO authenticates only on
// the serving host — so disable the SSO buttons and treat credentials as the
// active path even before both fields are filled.
const customHost = hostInput.value.trim().length > 0;
const credsFocus = has || customHost;
connectBtn.classList.toggle('btn-primary', has);
connectBtn.classList.toggle('btn-ghost', !has);
connectBtn.disabled = !has || !!busy;
for (const b of ssoBtns) {
b.classList.toggle('btn-primary', !has);
b.classList.toggle('btn-ghost', has);
b.classList.toggle('btn-primary', !credsFocus);
b.classList.toggle('btn-ghost', credsFocus);
b.disabled = customHost;
}
targetHostEl.textContent = hostInput.value.trim() || cur;
targetAsEl.textContent = has ? 'as ' + userInput.value.trim() : 'via SSO';
targetAsEl.textContent = has ? 'as ' + userInput.value.trim() : (customHost ? 'credentials' : 'via SSO');
}

function onCredsKey(e) { if (e.key === 'Enter' && hasCreds()) doConnect(); }
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ describe('createApp basics', () => {
const app = createApp(env({ location: { host: '', origin: 'o', pathname: '/sql' } }));
expect(app.host()).toBe('clickhouse');
});
it('reads the ?host= URL param into app.hostHint (empty when absent)', () => {
expect(createApp(env()).hostHint).toBe('');
const app = createApp(env({ location: { host: 'h', origin: 'https://h', pathname: '/sql', search: '?host=antalya.demo:9000' } }));
expect(app.hostHint).toBe('antalya.demo:9000');
});
});

describe('renderApp shell', () => {
Expand Down
57 changes: 53 additions & 4 deletions tests/unit/login.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ function appWith(over = {}) {
}

describe('renderLogin — structure', () => {
it('renders brand, headings, credentials, target row, and footer', () => {
it('renders brand, credentials, target row, and footer — no "Sign in" title/subtitle', () => {
const app = appWith();
renderLogin(app);
expect(app.root.querySelector('.login-brand-name').textContent).toContain('Altinity');
expect(app.root.querySelector('.login-h1').textContent).toBe('Sign in');
expect(app.root.querySelector('.login-h1')).toBeNull(); // title removed
expect(app.root.querySelector('.login-sub')).toBeNull(); // subtitle removed
expect(app.root.querySelectorAll('.login-input')).toHaveLength(3); // user, pass, host
expect(app.root.querySelector('.login-target .lt-as').textContent).toBe('via SSO');
expect(app.root.querySelector('.login-foot-link[href*="github.com"]')).not.toBeNull();
Expand Down Expand Up @@ -48,12 +49,12 @@ describe('renderLogin — SSO section', () => {
expect(app.root.querySelector('.login-divider').style.display).toBe('none');
expect(app.root.querySelector('.login-creds')).not.toBeNull();
});
it('one IdP → a single "Continue with SSO" button + divider shown', async () => {
it('one IdP → a single IdP-labelled button + divider shown', async () => {
const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) });
renderLogin(app);
await tick();
const btns = [...app.root.querySelectorAll('.login-sso .login-btn')];
expect(btns.map((b) => b.textContent)).toEqual(['Continue with SSO']);
expect(btns.map((b) => b.textContent)).toEqual(['Continue with Google']);
expect(app.root.querySelector('.login-divider').style.display).toBe('');
expect(app.root.querySelector('.login-sso-note').textContent).toContain('Authenticates on');
});
Expand All @@ -80,6 +81,54 @@ describe('renderLogin — SSO section', () => {
});
});

describe('renderLogin — footer tag adapts to available methods', () => {
const ver = (app) => app.root.querySelector('.login-foot-ver').textContent;
const render = async (over) => { const app = appWith(over); renderLogin(app); await tick(); return app; };

it('SSO + credentials', async () => {
expect(ver(await render({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }))).toBe('OAuth · credentials');
});
it('SSO only (basic_login:false)', async () => {
expect(ver(await render({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: false }) }))).toBe('OAuth');
});
it('credentials only (no IdPs)', async () => {
expect(ver(await render({ loadIdps: async () => ({ idps: [], basicLogin: true }) }))).toBe('credentials');
});
it('neither method configured', async () => {
expect(ver(await render({ loadIdps: async () => ({ idps: [], basicLogin: false }) }))).toBe('—');
});
it('config load failure → credentials-only', async () => {
expect(ver(await render({ loadIdps: async () => { throw new Error('x'); } }))).toBe('credentials');
});
});

describe('renderLogin — ?host= URL hint', () => {
it('pre-fills the server address, opens Advanced, disables SSO, targets credentials', async () => {
const app = appWith({
hostHint: 'antalya.demo:9000',
loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }),
});
renderLogin(app);
await tick();
const hostInput = app.root.querySelectorAll('.login-input')[2];
expect(hostInput.value).toBe('antalya.demo:9000');
expect(app.root.querySelector('.login-adv-field').style.display).toBe(''); // opened
const sso = app.root.querySelector('.login-sso .login-btn');
expect(sso.disabled).toBe(true); // SSO can't target a custom host
expect(app.root.querySelector('.lt-host').textContent).toBe('antalya.demo:9000');
expect(app.root.querySelector('.lt-as').textContent).toBe('credentials');
});
it('typing a host (no URL hint) also disables SSO', async () => {
const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) });
renderLogin(app);
await tick();
const sso = app.root.querySelector('.login-sso .login-btn');
expect(sso.disabled).toBe(false);
type(app.root.querySelectorAll('.login-input')[2], 'other:9000');
expect(sso.disabled).toBe(true);
});
});

describe('renderLogin — credentials reactivity', () => {
it('typing both fields flips Connect to primary and demotes SSO to ghost', async () => {
const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) });
Expand Down
16 changes: 15 additions & 1 deletion tests/unit/oauth-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('loadConfigDoc', () => {
})]]);
const { idps } = await loadConfigDoc(f, '/sql');
expect(idps).toEqual([{
id: 'accounts.google.com', label: 'accounts.google.com',
id: 'accounts.google.com', label: 'Google', // issuer host → friendly name
issuer: 'https://accounts.google.com', clientId: 'cid', clientSecret: 'sek',
audience: 'aud', bearer: 'id_token', chAuth: 'bearer', authorizeParams: {},
basicUserClaim: '',
Expand All @@ -51,8 +51,22 @@ describe('loadConfigDoc', () => {
{ issuer: 'weird', client_id: 'c' },
] });
expect(idps[0].id).toBe('acme.auth0.com');
expect(idps[0].label).toBe('acme.auth0.com'); // unknown host, no connection → host
expect(idps[1].id).toBe('weird'); // new URL('weird') throws → raw fallback
});
it('derives a friendly label from an Auth0 connection or a known issuer host', async () => {
const idps = await docOf({ idps: [
// Auth0 brokering GitHub: tenant host is uninformative → use the connection.
{ issuer: 'https://altinity.auth0.com', client_id: 'c', authorize_params: { connection: 'github' } },
// Unknown connection → capitalized.
{ issuer: 'https://x', client_id: 'c', authorize_params: { connection: 'okta-prod' } },
// Direct Google issuer, no label/connection → mapped.
{ issuer: 'https://accounts.google.com', client_id: 'c' },
// Explicit label always wins.
{ issuer: 'https://accounts.google.com', client_id: 'c', label: 'Staff SSO' },
] });
expect(idps.map((i) => i.label)).toEqual(['GitHub', 'Okta-prod', 'Google', 'Staff SSO']);
});
it('defaults clientSecret/audience/bearer/chAuth/authorizeParams', async () => {
const [idp] = await docOf({ issuer: 'https://i', client_id: 'c' });
expect(idp.clientSecret).toBe('');
Expand Down
Loading