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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ on your IdP and threat model. Common, all valid, variants:
The code treats `client_secret` as optional, so any of these is a config-only
choice.

#### Multiple IdPs

`config.json` may instead list several providers, and the login screen shows one
button per IdP ("Sign in with …"):

```json
{ "idps": [
{ "id": "google", "label": "Google", "issuer": "https://accounts.google.com", "client_id": "…" },
{ "id": "acme", "label": "Acme SSO", "issuer": "https://acme.auth0.com", "client_id": "…", "client_secret": "…" }
] }
```

Each entry takes the same fields as the single-IdP form (`issuer`, `client_id`,
optional `client_secret`/`audience`/`bearer`/`ch_auth`/`authorize_params`) plus an
optional `id`/`label` (default: the issuer host). A bare single object (above) is
still accepted — it's treated as a one-IdP list. ClickHouse needs a matching
`<token_processor>` per issuer; it validates each inbound JWT against whichever
one matches the token's `iss`, so no extra CH wiring is required to offer several.

### Security headers

`deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus
Expand Down
11 changes: 8 additions & 3 deletions deploy/config.json.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"issuer": "https://accounts.google.com",
"client_id": "REPLACE_WITH_OAUTH_CLIENT_ID",
"audience": ""
"idps": [
{
"id": "google",
"label": "Google",
"issuer": "https://accounts.google.com",
"client_id": "REPLACE_WITH_OAUTH_CLIENT_ID"
}
]
}
90 changes: 57 additions & 33 deletions src/net/oauth-config.js
Original file line number Diff line number Diff line change
@@ -1,55 +1,79 @@
// Loads the deployment's OAuth configuration: `./config.json` (issuer +
// client_id [+ optional client_secret/audience]) followed by the issuer's
// OIDC discovery document to resolve the authorize/token endpoints.
// Loads the deployment's OAuth configuration from `./config.json` — either a
// single IdP (a bare object, legacy) or several (`{ idps: [...] }`).
// `loadConfigDoc` fetches + normalizes the list; `resolveIdp` runs the issuer's
// OIDC discovery for one chosen IdP into the object the oauth module consumes.
//
// `fetchFn` is injected so this is fully testable without a network. The
// returned object is the canonical config the oauth module consumes.
// `fetchFn` is injected so this is fully testable without a network.

/** Host of an issuer URL, used as the default id/label. Falls back to the raw string. */
function idpHost(issuer) {
try {
return new URL(issuer).host;
} catch {
return issuer;
}
}

/** 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),
issuer: e.issuer,
clientId: e.client_id,
clientSecret: e.client_secret || '',
audience: e.audience || '',
// Which token to send to ClickHouse: 'id_token' (default; forward-mode CH)
// or 'access_token' (audience-gated CH).
bearer: e.bearer === 'access_token' ? 'access_token' : 'id_token',
// How the token reaches ClickHouse: 'bearer' (default; Authorization: Bearer
// <jwt>) or 'basic' (Authorization: Basic base64(email:jwt), for OSS CH
// behind a verifier such as ch-jwt-verify).
chAuth: e.ch_auth === 'basic' ? 'basic' : 'bearer',
// Extra params merged into /authorize (e.g. Auth0 { organization: 'org_…' }).
authorizeParams: e.authorize_params && typeof e.authorize_params === 'object'
? e.authorize_params
: {},
};
}

/**
* Fetch config.json and normalize to `{ idps: [descriptor, ...] }`. Accepts a
* list (`{ idps: [...] }`) or a single bare object (legacy) wrapped into one
* entry. Throws if no usable IdP is present.
* @param {(url: string, init?: object) => Promise<Response>} fetchFn
* @param {string} basePath e.g. location.pathname ('/sql')
* @returns {Promise<{clientId,clientSecret,audience,authUri,tokenUri,issuer}>}
*/
export async function loadOAuthConfig(fetchFn, basePath = '') {
export async function loadConfigDoc(fetchFn, basePath = '') {
const cfgUrl = basePath.replace(/\/$/, '') + '/config.json';
const cfgResp = await fetchFn(cfgUrl, { cache: 'no-store' });
if (!cfgResp.ok) throw new Error('GET ' + cfgUrl + ': ' + cfgResp.status);
const cfg = await cfgResp.json();
if (!cfg.issuer || !cfg.client_id) {
throw new Error('config.json missing issuer or client_id');
}
const discUrl = cfg.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const list = Array.isArray(cfg.idps) ? cfg.idps : [cfg];
if (!list.length) throw new Error('config.json has no IdPs');
return { idps: list.map(normalizeEntry) };
}

/**
* Resolve one IdP's authorize/token endpoints via OIDC discovery. Returns the
* descriptor extended with `authUri`/`tokenUri` — the object oauth.js consumes.
*/
export async function resolveIdp(fetchFn, idp) {
const discUrl = idp.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const discResp = await fetchFn(discUrl, { cache: 'no-store' });
if (!discResp.ok) throw new Error('OIDC discovery failed: ' + discResp.status);
const disc = await discResp.json();
if (!disc.authorization_endpoint || !disc.token_endpoint) {
throw new Error('OIDC discovery missing authorization_endpoint or token_endpoint');
}
return {
issuer: cfg.issuer,
clientId: cfg.client_id,
clientSecret: cfg.client_secret || '',
audience: cfg.audience || '',
authUri: disc.authorization_endpoint,
tokenUri: disc.token_endpoint,
// Which token to send to ClickHouse: 'id_token' (default; forward-mode CH
// that doesn't enforce audience) or 'access_token' (audience-gated CH).
bearer: cfg.bearer === 'access_token' ? 'access_token' : 'id_token',
// How the token reaches ClickHouse: 'bearer' (default; Authorization:
// Bearer <jwt>, for a CH token_processor) or 'basic' (Authorization: Basic
// base64(email:jwt), for OSS CH behind an http_authentication_servers
// verifier such as ch-jwt-verify where the JWT is the Basic password).
chAuth: cfg.ch_auth === 'basic' ? 'basic' : 'bearer',
// Extra params merged into the /authorize request (e.g. Auth0
// { "organization": "org_…" }). Pass-through, no interpretation.
authorizeParams: cfg.authorize_params && typeof cfg.authorize_params === 'object'
? cfg.authorize_params
: {},
};
return { ...idp, authUri: disc.authorization_endpoint, tokenUri: disc.token_endpoint };
}

/**
* Memoize a loader so config + discovery are fetched once. Returns a function
* Memoize a loader so the config document is fetched once. Returns a function
* with the same signature; a failed load is not cached (so a retry re-fetches).
*/
export function memoizeConfig(loader) {
Expand Down
1 change: 1 addition & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ body {
content: ''; width: 6px; height: 6px; border-radius: 4px;
background: #22c55e; box-shadow: 0 0 6px #22c55e;
}
.login-actions { display: flex; flex-direction: column; gap: 8px; }
.login-btn {
width: 100%; height: 40px; padding: 0 16px;
background: var(--accent); color: #fff;
Expand Down
33 changes: 25 additions & 8 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,22 @@ export function createApp(env = {}) {
refreshToken: ss.getItem('oauth_refresh_token'),
};

const loadConfig = oauthCfg.memoizeConfig(() => oauthCfg.loadOAuthConfig(fetchFn, loc.pathname));
// config.json may list several IdPs. Fetch the doc once; resolve OIDC
// discovery per selected IdP. The chosen IdP id is persisted so it survives
// the OAuth redirect (like oauth_state) and drives token exchange/refresh.
const loadDoc = oauthCfg.memoizeConfig(() => oauthCfg.loadConfigDoc(fetchFn, loc.pathname));
const resolvedCache = new Map();
app.idpId = ss.getItem('oauth_idp') || null;
function selectIdp(id) { app.idpId = id; ss.setItem('oauth_idp', id); }
async function resolveConfig() {
const { idps } = await loadDoc();
const chosen = idps.find((i) => i.id === app.idpId) || idps[0];
app.idpId = chosen.id;
if (!resolvedCache.has(chosen.id)) resolvedCache.set(chosen.id, oauthCfg.resolveIdp(fetchFn, chosen));
return resolvedCache.get(chosen.id);
}
app.loadIdps = loadDoc;
app.selectIdp = selectIdp;

// --- persistence -------------------------------------------------------
app.saveJSON = saveJSON;
Expand Down Expand Up @@ -75,18 +90,20 @@ export function createApp(env = {}) {
function clearTokens() {
app.token = null;
app.refreshToken = null;
['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state'].forEach((k) => ss.removeItem(k));
app.idpId = null;
['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp'].forEach((k) => ss.removeItem(k));
}
app.setTokens = setTokens;
app.clearTokens = clearTokens;
app.loadConfig = loadConfig;
app.loadConfig = resolveConfig;

app.signOut = () => { clearTokens(); renderLogin(app); };
app.showLogin = (msg) => renderLogin(app, msg);

// --- OAuth -------------------------------------------------------------
async function login() {
const cfg = await loadConfig();
async function login(idpId) {
if (idpId) selectIdp(idpId);
const cfg = await resolveConfig();
const { verifier, challenge } = await generatePKCE(cryptoObj);
const state = randomState(cryptoObj);
ss.setItem('oauth_verifier', verifier);
Expand All @@ -99,7 +116,7 @@ export function createApp(env = {}) {
}

async function refresh() {
const cfg = await loadConfig();
const cfg = await resolveConfig();
const tokens = await oauth.refreshTokens(fetchFn, cfg, app.refreshToken);
const bearer = oauth.bearerFromTokens(tokens, cfg.bearer);
if (!bearer) return false;
Expand Down Expand Up @@ -141,7 +158,7 @@ export function createApp(env = {}) {
// rather than blocking the query.
async function ensureConfig() {
try {
const cfg = await loadConfig();
const cfg = await resolveConfig();
app.chAuth = cfg.chAuth;
return cfg;
} catch {
Expand Down Expand Up @@ -348,7 +365,7 @@ export function createApp(env = {}) {
selectTab: (id) => selectTab(app, id),
closeTab: (id) => closeTab(app, id),
loadIntoNewTab: (name, sql) => loadIntoNewTab(app, name, sql),
login,
login: (idpId) => login(idpId),
share,
toggleSaved: toggleSavedActive,
formatQuery,
Expand Down
59 changes: 40 additions & 19 deletions src/ui/login.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,61 @@
// The sign-in screen.
// The sign-in screen. With several configured IdPs it shows one button per
// provider; with a single IdP (or a legacy single-object config) it shows one
// plain "Sign in" button.

import { h } from './dom.js';

// A sign-in button carrying the disable → "Redirecting…" → restore-on-error
// flow. `idpId` is undefined for the single-IdP default (login() picks the only
// one); otherwise it selects that provider.
function signInButton(app, label, idpId) {
return h('button', {
class: 'login-btn',
onclick: async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
btn.textContent = 'Redirecting…';
try {
await app.actions.login(idpId);
} catch (err) {
btn.disabled = false;
btn.textContent = label;
app.showLogin(String((err && err.message) || err));
}
},
}, label);
}

/**
* Render the login screen into `root`. `app` provides:
* host() — environment label
* actions.login() — start the OAuth flow (async, may throw)
* showLogin(msg) — re-render with an error message
* host() — environment label
* actions.login(id?) — start the OAuth flow for IdP `id` (async, may throw)
* loadIdps() — resolve the configured IdP list (async)
* showLogin(msg) — re-render with an error message
*/
export function renderLogin(app, errorMsg) {
const root = app.root;
const actions = h('div', { class: 'login-actions' }, signInButton(app, 'Sign in'));
root.replaceChildren(
h('div', { class: 'login-screen' },
h('div', { class: 'login-card' },
h('div', { class: 'login-logo' }, 'A'),
h('div', { class: 'login-title' }, 'Altinity SQL Browser'),
h('div', { class: 'login-sub' }, 'Sign in to continue'),
h('div', { class: 'login-env' }, app.host()),
h('button', {
class: 'login-btn',
onclick: async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
btn.textContent = 'Redirecting…';
try {
await app.actions.login();
} catch (err) {
btn.disabled = false;
btn.textContent = 'Sign in';
app.showLogin(String((err && err.message) || err));
}
},
}, 'Sign in'),
actions,
errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null,
h('div', { class: 'login-foot' }, 'OAuth · OIDC discovery'),
),
),
);
// With multiple IdPs, swap the single button for one button per provider.
if (app.loadIdps) {
app.loadIdps().then(({ idps }) => {
if (idps && idps.length > 1) {
actions.replaceChildren(
...idps.map((idp) => signInButton(app, 'Sign in with ' + idp.label, idp.id)),
);
}
}).catch(() => { /* keep the single button; a click surfaces the config error */ });
}
}
23 changes: 23 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,29 @@ describe('auth flows', () => {
expect(e.sessionStorage.getItem('oauth_verifier')).toBeTruthy();
expect(e.sessionStorage.getItem('oauth_state')).toBeTruthy();
});
it('multi-IdP: login(id) selects that IdP, persists it, and uses its endpoints', async () => {
const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' };
const e = env({
location: loc,
sessionStorage: memSession({}),
fetch: makeFetch([
[(u) => /config\.json/.test(u), resp({ json: { idps: [
{ id: 'google', issuer: 'https://accounts.google.com', client_id: 'g' },
{ id: 'auth0', issuer: 'https://acme.auth0.com', client_id: 'a' },
] } })],
[(u) => /acme\.auth0\.com\/.well-known/.test(u), resp({ json: { authorization_endpoint: 'https://acme.auth0.com/authorize', token_endpoint: 'https://acme.auth0.com/t' } })],
[(u) => /accounts\.google\.com\/.well-known/.test(u), resp({ json: { authorization_endpoint: 'https://accounts.google.com/auth', token_endpoint: 'https://t' } })],
]),
});
const app = createApp(e);
expect((await app.loadIdps()).idps).toHaveLength(2);
await app.actions.login('auth0');
expect(loc.href).toContain('https://acme.auth0.com/authorize?');
expect(loc.href).toContain('client_id=a');
expect(e.sessionStorage.getItem('oauth_idp')).toBe('auth0');
app.signOut();
expect(e.sessionStorage.getItem('oauth_idp')).toBeNull(); // cleared on sign-out
});
it('refresh succeeds via the ClickHouse context', async () => {
const e = env({
sessionStorage: memSession({ oauth_id_token: jwt({ exp: 1 }), oauth_refresh_token: 'rt' }),
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/login.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,33 @@ describe('renderLogin', () => {
await tick();
expect(showLogin).toHaveBeenCalledWith('rawstr');
});

it('multiple IdPs → one button per provider, clicking passes the IdP id', async () => {
const login = vi.fn(async () => {});
const app = makeApp({
actions: { ...makeApp().actions, login },
loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }, { id: 'a', label: 'Acme SSO' }] }),
});
renderLogin(app);
await tick();
const btns = [...app.root.querySelectorAll('.login-btn')];
expect(btns.map((b) => b.textContent)).toEqual(['Sign in with Google', 'Sign in with Acme SSO']);
btns[1].dispatchEvent(new Event('click'));
await tick();
expect(login).toHaveBeenCalledWith('a');
});
it('a single IdP keeps the lone Sign in button', async () => {
const app = makeApp({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }] }) });
renderLogin(app);
await tick();
const btns = [...app.root.querySelectorAll('.login-btn')];
expect(btns).toHaveLength(1);
expect(btns[0].textContent).toBe('Sign in');
});
it('keeps the single button when the IdP list fails to load', async () => {
const app = makeApp({ loadIdps: async () => { throw new Error('no config'); } });
renderLogin(app);
await tick();
expect(app.root.querySelectorAll('.login-btn')).toHaveLength(1);
});
});
Loading
Loading