From d55dea3a214dcf65a72b0fde6c0d3a667d372154 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Mon, 22 Jun 2026 17:36:18 +0200 Subject: [PATCH 1/3] feat(login): IdP-labelled SSO buttons + subtitle/footer adapt to available methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSO button is always labelled with the IdP ("Continue with Google") instead of a generic "Continue with SSO" for the single-IdP case — reads better and disambiguates multiple providers. (Label comes from the config `label`, which falls back to the issuer host, so deployments should set a friendly `label`.) - The card subtitle and footer tag now reflect which methods are actually offered: SSO+credentials, SSO-only (`basic_login:false` → no "credentials" phrase, footer "OAuth"), credentials-only (no idps), or neither. Previously the copy hard-coded "…or connect with ClickHouse credentials" / "OAuth · credentials" even when the credentials path was hidden. login.js stays at 100% coverage; added a suite covering the four method combinations + the config-load-failure path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/ui/login.js | 34 ++++++++++++++++++++++++++-------- tests/unit/login.test.js | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/ui/login.js b/src/ui/login.js index d3fa739..54edd1e 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -93,6 +93,11 @@ export function renderLogin(app, errorMsg) { h('span', { style: { flex: '1' } }), targetAsEl)); + // Subtitle + footer tag adapt to which methods are actually available + // (filled in by applyChrome once the IdP list / basic_login flag resolve). + const subEl = h('div', { class: 'login-sub' }, 'Sign in to continue.'); + 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'), @@ -100,7 +105,7 @@ export function renderLogin(app, errorMsg) { 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.'), + subEl, ssoSection, credSection, errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null, @@ -110,7 +115,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(); @@ -119,11 +124,24 @@ 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'; + subEl.textContent = + hasSso && credsShown ? 'Use single sign-on for this server, or connect with ClickHouse credentials.' + : hasSso ? 'Use single sign-on for this server.' + : credsShown ? 'Connect with your ClickHouse username and password.' + : 'No sign-in method is configured — check config.json.'; + footVer.textContent = [hasSso && 'OAuth', credsShown && 'credentials'].filter(Boolean).join(' · ') || '—'; + } function populateSso(idps) { ssoBtns = []; @@ -134,9 +152,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' }, diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index 61a0ca3..92e77b6 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -48,12 +48,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'); }); @@ -80,6 +80,39 @@ describe('renderLogin — SSO section', () => { }); }); +describe('renderLogin — subtitle/footer adapt to available methods', () => { + const sub = (app) => app.root.querySelector('.login-sub').textContent; + 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 → both mentioned', async () => { + const app = await render({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }); + expect(sub(app)).toMatch(/single sign-on.*credentials/); + expect(ver(app)).toBe('OAuth · credentials'); + }); + it('SSO only (basic_login:false) → no credentials phrase', async () => { + const app = await render({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: false }) }); + expect(sub(app)).toBe('Use single sign-on for this server.'); + expect(sub(app)).not.toMatch(/credentials/); + expect(ver(app)).toBe('OAuth'); + }); + it('credentials only (no IdPs) → no SSO phrase', async () => { + const app = await render({ loadIdps: async () => ({ idps: [], basicLogin: true }) }); + expect(sub(app)).toMatch(/username and password/); + expect(ver(app)).toBe('credentials'); + }); + it('neither method → explains nothing is configured', async () => { + const app = await render({ loadIdps: async () => ({ idps: [], basicLogin: false }) }); + expect(sub(app)).toMatch(/No sign-in method/); + expect(ver(app)).toBe('—'); + }); + it('config load failure → credentials-only chrome', async () => { + const app = await render({ loadIdps: async () => { throw new Error('x'); } }); + expect(sub(app)).toMatch(/username and password/); + expect(ver(app)).toBe('credentials'); + }); +}); + 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 }) }); From 1ed02e68ed89bed4b81535d6c04029283a1ae1c2 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Mon, 22 Jun 2026 17:51:18 +0200 Subject: [PATCH 2/3] feat(login): drop the Sign-in title/subtitle; ?host= prefills Advanced + disables SSO --- README.md | 6 +++- src/ui/app.js | 3 ++ src/ui/login.js | 49 +++++++++++++++-------------- tests/unit/app.test.js | 5 +++ tests/unit/login.test.js | 66 +++++++++++++++++++++++++--------------- 5 files changed, 81 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 760473e..e78a88f 100644 --- a/README.md +++ b/README.md @@ -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'` diff --git a/src/ui/app.js b/src/ui/app.js index b002eaf..a29e6d8 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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)); diff --git a/src/ui/login.js b/src/ui/login.js index 54edd1e..bc5df02 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -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 / 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 / via SSO). import { h } from './dom.js'; import { Icon } from './icons.js'; @@ -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; @@ -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', @@ -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 }, @@ -93,9 +99,9 @@ export function renderLogin(app, errorMsg) { h('span', { style: { flex: '1' } }), targetAsEl)); - // Subtitle + footer tag adapt to which methods are actually available - // (filled in by applyChrome once the IdP list / basic_login flag resolve). - const subEl = h('div', { class: 'login-sub' }, 'Sign in to continue.'); + // 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' }, @@ -104,8 +110,6 @@ export function renderLogin(app, errorMsg) { 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'), - subEl, ssoSection, credSection, errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null, @@ -135,11 +139,6 @@ export function renderLogin(app, errorMsg) { // sign-in methods are actually offered. function applyChrome(hasSso, credsShown) { divider.style.display = (hasSso && credsShown) ? '' : 'none'; - subEl.textContent = - hasSso && credsShown ? 'Use single sign-on for this server, or connect with ClickHouse credentials.' - : hasSso ? 'Use single sign-on for this server.' - : credsShown ? 'Connect with your ClickHouse username and password.' - : 'No sign-in method is configured — check config.json.'; footVer.textContent = [hasSso && 'OAuth', credsShown && 'credentials'].filter(Boolean).join(' · ') || '—'; } @@ -165,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(); } diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 91ceffa..42a2ac6 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -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', () => { diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index 92e77b6..8038425 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -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(); @@ -80,36 +81,51 @@ describe('renderLogin — SSO section', () => { }); }); -describe('renderLogin — subtitle/footer adapt to available methods', () => { - const sub = (app) => app.root.querySelector('.login-sub').textContent; +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 → both mentioned', async () => { - const app = await render({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }); - expect(sub(app)).toMatch(/single sign-on.*credentials/); - expect(ver(app)).toBe('OAuth · credentials'); + 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) → no credentials phrase', async () => { - const app = await render({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: false }) }); - expect(sub(app)).toBe('Use single sign-on for this server.'); - expect(sub(app)).not.toMatch(/credentials/); - expect(ver(app)).toBe('OAuth'); + 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) → no SSO phrase', async () => { - const app = await render({ loadIdps: async () => ({ idps: [], basicLogin: true }) }); - expect(sub(app)).toMatch(/username and password/); - expect(ver(app)).toBe('credentials'); + it('credentials only (no IdPs)', async () => { + expect(ver(await render({ loadIdps: async () => ({ idps: [], basicLogin: true }) }))).toBe('credentials'); }); - it('neither method → explains nothing is configured', async () => { - const app = await render({ loadIdps: async () => ({ idps: [], basicLogin: false }) }); - expect(sub(app)).toMatch(/No sign-in method/); - expect(ver(app)).toBe('—'); + it('neither method configured', async () => { + expect(ver(await render({ loadIdps: async () => ({ idps: [], basicLogin: false }) }))).toBe('—'); }); - it('config load failure → credentials-only chrome', async () => { - const app = await render({ loadIdps: async () => { throw new Error('x'); } }); - expect(sub(app)).toMatch(/username and password/); - expect(ver(app)).toBe('credentials'); + 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); }); }); From 69533432777aad73c55a06c24fea27b06184af79 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Mon, 22 Jun 2026 18:00:12 +0200 Subject: [PATCH 3/3] feat(login): derive a friendly IdP label when config sets none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons are label-driven; without an explicit `label` a config showed the issuer host ("Continue with altinity.auth0.com"). Derive a friendly name: explicit `label` → Auth0 `authorize_params.connection` (github → "GitHub") → known issuer host (accounts.google.com → "Google") → issuer host. This fixes the demo clusters with no server-side change: github.demo (connection=github) → "Continue with GitHub", antalya (accounts.google.com) → "Continue with Google" — their `config.json` is inline in CHOP-managed ConfigMaps, so editing it would risk a pod restart (and github.demo crashes on restart). Deploying the asset is enough. oauth-config.js stays at 100%. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/net/oauth-config.js | 23 ++++++++++++++++++++++- tests/unit/oauth-config.test.js | 16 +++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js index 8275a53..004d0e2 100644 --- a/src/net/oauth-config.js +++ b/src/net/oauth-config.js @@ -14,6 +14,27 @@ 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) { @@ -21,7 +42,7 @@ function normalizeEntry(e) { } 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 || '', diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js index 3f60acd..fba6e37 100644 --- a/tests/unit/oauth-config.test.js +++ b/tests/unit/oauth-config.test.js @@ -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: '', @@ -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('');