Skip to content

Commit fffca86

Browse files
Merge pull request #4 from Altinity/feat/multi-idp-config
feat(auth): multi-IdP config.json + login provider picker (phase 1)
2 parents d3c5027 + 975c2d4 commit fffca86

9 files changed

Lines changed: 272 additions & 132 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,25 @@ on your IdP and threat model. Common, all valid, variants:
8888
The code treats `client_secret` as optional, so any of these is a config-only
8989
choice.
9090

91+
#### Multiple IdPs
92+
93+
`config.json` may instead list several providers, and the login screen shows one
94+
button per IdP ("Sign in with …"):
95+
96+
```json
97+
{ "idps": [
98+
{ "id": "google", "label": "Google", "issuer": "https://accounts.google.com", "client_id": "" },
99+
{ "id": "acme", "label": "Acme SSO", "issuer": "https://acme.auth0.com", "client_id": "", "client_secret": "" }
100+
] }
101+
```
102+
103+
Each entry takes the same fields as the single-IdP form (`issuer`, `client_id`,
104+
optional `client_secret`/`audience`/`bearer`/`ch_auth`/`authorize_params`) plus an
105+
optional `id`/`label` (default: the issuer host). A bare single object (above) is
106+
still accepted — it's treated as a one-IdP list. ClickHouse needs a matching
107+
`<token_processor>` per issuer; it validates each inbound JWT against whichever
108+
one matches the token's `iss`, so no extra CH wiring is required to offer several.
109+
91110
### Security headers
92111

93112
`deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus

deploy/config.json.example

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
{
2-
"issuer": "https://accounts.google.com",
3-
"client_id": "REPLACE_WITH_OAUTH_CLIENT_ID",
4-
"audience": ""
2+
"idps": [
3+
{
4+
"id": "google",
5+
"label": "Google",
6+
"issuer": "https://accounts.google.com",
7+
"client_id": "REPLACE_WITH_OAUTH_CLIENT_ID"
8+
}
9+
]
510
}

src/net/oauth-config.js

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,79 @@
1-
// Loads the deployment's OAuth configuration: `./config.json` (issuer +
2-
// client_id [+ optional client_secret/audience]) followed by the issuer's
3-
// OIDC discovery document to resolve the authorize/token endpoints.
1+
// Loads the deployment's OAuth configuration from `./config.json` — either a
2+
// single IdP (a bare object, legacy) or several (`{ idps: [...] }`).
3+
// `loadConfigDoc` fetches + normalizes the list; `resolveIdp` runs the issuer's
4+
// OIDC discovery for one chosen IdP into the object the oauth module consumes.
45
//
5-
// `fetchFn` is injected so this is fully testable without a network. The
6-
// returned object is the canonical config the oauth module consumes.
6+
// `fetchFn` is injected so this is fully testable without a network.
7+
8+
/** Host of an issuer URL, used as the default id/label. Falls back to the raw string. */
9+
function idpHost(issuer) {
10+
try {
11+
return new URL(issuer).host;
12+
} catch {
13+
return issuer;
14+
}
15+
}
16+
17+
/** Map one raw config.json entry to the canonical (pre-discovery) IdP descriptor. */
18+
function normalizeEntry(e) {
19+
if (!e || !e.issuer || !e.client_id) {
20+
throw new Error('config.json IdP missing issuer or client_id');
21+
}
22+
return {
23+
id: e.id || idpHost(e.issuer),
24+
label: e.label || idpHost(e.issuer),
25+
issuer: e.issuer,
26+
clientId: e.client_id,
27+
clientSecret: e.client_secret || '',
28+
audience: e.audience || '',
29+
// Which token to send to ClickHouse: 'id_token' (default; forward-mode CH)
30+
// or 'access_token' (audience-gated CH).
31+
bearer: e.bearer === 'access_token' ? 'access_token' : 'id_token',
32+
// How the token reaches ClickHouse: 'bearer' (default; Authorization: Bearer
33+
// <jwt>) or 'basic' (Authorization: Basic base64(email:jwt), for OSS CH
34+
// behind a verifier such as ch-jwt-verify).
35+
chAuth: e.ch_auth === 'basic' ? 'basic' : 'bearer',
36+
// Extra params merged into /authorize (e.g. Auth0 { organization: 'org_…' }).
37+
authorizeParams: e.authorize_params && typeof e.authorize_params === 'object'
38+
? e.authorize_params
39+
: {},
40+
};
41+
}
742

843
/**
44+
* Fetch config.json and normalize to `{ idps: [descriptor, ...] }`. Accepts a
45+
* list (`{ idps: [...] }`) or a single bare object (legacy) wrapped into one
46+
* entry. Throws if no usable IdP is present.
947
* @param {(url: string, init?: object) => Promise<Response>} fetchFn
1048
* @param {string} basePath e.g. location.pathname ('/sql')
11-
* @returns {Promise<{clientId,clientSecret,audience,authUri,tokenUri,issuer}>}
1249
*/
13-
export async function loadOAuthConfig(fetchFn, basePath = '') {
50+
export async function loadConfigDoc(fetchFn, basePath = '') {
1451
const cfgUrl = basePath.replace(/\/$/, '') + '/config.json';
1552
const cfgResp = await fetchFn(cfgUrl, { cache: 'no-store' });
1653
if (!cfgResp.ok) throw new Error('GET ' + cfgUrl + ': ' + cfgResp.status);
1754
const cfg = await cfgResp.json();
18-
if (!cfg.issuer || !cfg.client_id) {
19-
throw new Error('config.json missing issuer or client_id');
20-
}
21-
const discUrl = cfg.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
55+
const list = Array.isArray(cfg.idps) ? cfg.idps : [cfg];
56+
if (!list.length) throw new Error('config.json has no IdPs');
57+
return { idps: list.map(normalizeEntry) };
58+
}
59+
60+
/**
61+
* Resolve one IdP's authorize/token endpoints via OIDC discovery. Returns the
62+
* descriptor extended with `authUri`/`tokenUri` — the object oauth.js consumes.
63+
*/
64+
export async function resolveIdp(fetchFn, idp) {
65+
const discUrl = idp.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
2266
const discResp = await fetchFn(discUrl, { cache: 'no-store' });
2367
if (!discResp.ok) throw new Error('OIDC discovery failed: ' + discResp.status);
2468
const disc = await discResp.json();
2569
if (!disc.authorization_endpoint || !disc.token_endpoint) {
2670
throw new Error('OIDC discovery missing authorization_endpoint or token_endpoint');
2771
}
28-
return {
29-
issuer: cfg.issuer,
30-
clientId: cfg.client_id,
31-
clientSecret: cfg.client_secret || '',
32-
audience: cfg.audience || '',
33-
authUri: disc.authorization_endpoint,
34-
tokenUri: disc.token_endpoint,
35-
// Which token to send to ClickHouse: 'id_token' (default; forward-mode CH
36-
// that doesn't enforce audience) or 'access_token' (audience-gated CH).
37-
bearer: cfg.bearer === 'access_token' ? 'access_token' : 'id_token',
38-
// How the token reaches ClickHouse: 'bearer' (default; Authorization:
39-
// Bearer <jwt>, for a CH token_processor) or 'basic' (Authorization: Basic
40-
// base64(email:jwt), for OSS CH behind an http_authentication_servers
41-
// verifier such as ch-jwt-verify where the JWT is the Basic password).
42-
chAuth: cfg.ch_auth === 'basic' ? 'basic' : 'bearer',
43-
// Extra params merged into the /authorize request (e.g. Auth0
44-
// { "organization": "org_…" }). Pass-through, no interpretation.
45-
authorizeParams: cfg.authorize_params && typeof cfg.authorize_params === 'object'
46-
? cfg.authorize_params
47-
: {},
48-
};
72+
return { ...idp, authUri: disc.authorization_endpoint, tokenUri: disc.token_endpoint };
4973
}
5074

5175
/**
52-
* Memoize a loader so config + discovery are fetched once. Returns a function
76+
* Memoize a loader so the config document is fetched once. Returns a function
5377
* with the same signature; a failed load is not cached (so a retry re-fetches).
5478
*/
5579
export function memoizeConfig(loader) {

src/styles.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ body {
115115
content: ''; width: 6px; height: 6px; border-radius: 4px;
116116
background: #22c55e; box-shadow: 0 0 6px #22c55e;
117117
}
118+
.login-actions { display: flex; flex-direction: column; gap: 8px; }
118119
.login-btn {
119120
width: 100%; height: 40px; padding: 0 16px;
120121
background: var(--accent); color: #fff;

src/ui/app.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,22 @@ export function createApp(env = {}) {
4545
refreshToken: ss.getItem('oauth_refresh_token'),
4646
};
4747

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

5065
// --- persistence -------------------------------------------------------
5166
app.saveJSON = saveJSON;
@@ -75,18 +90,20 @@ export function createApp(env = {}) {
7590
function clearTokens() {
7691
app.token = null;
7792
app.refreshToken = null;
78-
['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state'].forEach((k) => ss.removeItem(k));
93+
app.idpId = null;
94+
['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp'].forEach((k) => ss.removeItem(k));
7995
}
8096
app.setTokens = setTokens;
8197
app.clearTokens = clearTokens;
82-
app.loadConfig = loadConfig;
98+
app.loadConfig = resolveConfig;
8399

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

87103
// --- OAuth -------------------------------------------------------------
88-
async function login() {
89-
const cfg = await loadConfig();
104+
async function login(idpId) {
105+
if (idpId) selectIdp(idpId);
106+
const cfg = await resolveConfig();
90107
const { verifier, challenge } = await generatePKCE(cryptoObj);
91108
const state = randomState(cryptoObj);
92109
ss.setItem('oauth_verifier', verifier);
@@ -99,7 +116,7 @@ export function createApp(env = {}) {
99116
}
100117

101118
async function refresh() {
102-
const cfg = await loadConfig();
119+
const cfg = await resolveConfig();
103120
const tokens = await oauth.refreshTokens(fetchFn, cfg, app.refreshToken);
104121
const bearer = oauth.bearerFromTokens(tokens, cfg.bearer);
105122
if (!bearer) return false;
@@ -141,7 +158,7 @@ export function createApp(env = {}) {
141158
// rather than blocking the query.
142159
async function ensureConfig() {
143160
try {
144-
const cfg = await loadConfig();
161+
const cfg = await resolveConfig();
145162
app.chAuth = cfg.chAuth;
146163
return cfg;
147164
} catch {
@@ -348,7 +365,7 @@ export function createApp(env = {}) {
348365
selectTab: (id) => selectTab(app, id),
349366
closeTab: (id) => closeTab(app, id),
350367
loadIntoNewTab: (name, sql) => loadIntoNewTab(app, name, sql),
351-
login,
368+
login: (idpId) => login(idpId),
352369
share,
353370
toggleSaved: toggleSavedActive,
354371
formatQuery,

src/ui/login.js

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,61 @@
1-
// The sign-in screen.
1+
// The sign-in screen. With several configured IdPs it shows one button per
2+
// provider; with a single IdP (or a legacy single-object config) it shows one
3+
// plain "Sign in" button.
24

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

7+
// A sign-in button carrying the disable → "Redirecting…" → restore-on-error
8+
// flow. `idpId` is undefined for the single-IdP default (login() picks the only
9+
// one); otherwise it selects that provider.
10+
function signInButton(app, label, idpId) {
11+
return h('button', {
12+
class: 'login-btn',
13+
onclick: async (e) => {
14+
const btn = e.currentTarget;
15+
btn.disabled = true;
16+
btn.textContent = 'Redirecting…';
17+
try {
18+
await app.actions.login(idpId);
19+
} catch (err) {
20+
btn.disabled = false;
21+
btn.textContent = label;
22+
app.showLogin(String((err && err.message) || err));
23+
}
24+
},
25+
}, label);
26+
}
27+
528
/**
629
* Render the login screen into `root`. `app` provides:
7-
* host() — environment label
8-
* actions.login() — start the OAuth flow (async, may throw)
9-
* showLogin(msg) — re-render with an error message
30+
* host() — environment label
31+
* actions.login(id?) — start the OAuth flow for IdP `id` (async, may throw)
32+
* loadIdps() — resolve the configured IdP list (async)
33+
* showLogin(msg) — re-render with an error message
1034
*/
1135
export function renderLogin(app, errorMsg) {
1236
const root = app.root;
37+
const actions = h('div', { class: 'login-actions' }, signInButton(app, 'Sign in'));
1338
root.replaceChildren(
1439
h('div', { class: 'login-screen' },
1540
h('div', { class: 'login-card' },
1641
h('div', { class: 'login-logo' }, 'A'),
1742
h('div', { class: 'login-title' }, 'Altinity SQL Browser'),
1843
h('div', { class: 'login-sub' }, 'Sign in to continue'),
1944
h('div', { class: 'login-env' }, app.host()),
20-
h('button', {
21-
class: 'login-btn',
22-
onclick: async (e) => {
23-
const btn = e.currentTarget;
24-
btn.disabled = true;
25-
btn.textContent = 'Redirecting…';
26-
try {
27-
await app.actions.login();
28-
} catch (err) {
29-
btn.disabled = false;
30-
btn.textContent = 'Sign in';
31-
app.showLogin(String((err && err.message) || err));
32-
}
33-
},
34-
}, 'Sign in'),
45+
actions,
3546
errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null,
3647
h('div', { class: 'login-foot' }, 'OAuth · OIDC discovery'),
3748
),
3849
),
3950
);
51+
// With multiple IdPs, swap the single button for one button per provider.
52+
if (app.loadIdps) {
53+
app.loadIdps().then(({ idps }) => {
54+
if (idps && idps.length > 1) {
55+
actions.replaceChildren(
56+
...idps.map((idp) => signInButton(app, 'Sign in with ' + idp.label, idp.id)),
57+
);
58+
}
59+
}).catch(() => { /* keep the single button; a click surfaces the config error */ });
60+
}
4061
}

tests/unit/app.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,29 @@ describe('auth flows', () => {
323323
expect(e.sessionStorage.getItem('oauth_verifier')).toBeTruthy();
324324
expect(e.sessionStorage.getItem('oauth_state')).toBeTruthy();
325325
});
326+
it('multi-IdP: login(id) selects that IdP, persists it, and uses its endpoints', async () => {
327+
const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' };
328+
const e = env({
329+
location: loc,
330+
sessionStorage: memSession({}),
331+
fetch: makeFetch([
332+
[(u) => /config\.json/.test(u), resp({ json: { idps: [
333+
{ id: 'google', issuer: 'https://accounts.google.com', client_id: 'g' },
334+
{ id: 'auth0', issuer: 'https://acme.auth0.com', client_id: 'a' },
335+
] } })],
336+
[(u) => /acme\.auth0\.com\/.well-known/.test(u), resp({ json: { authorization_endpoint: 'https://acme.auth0.com/authorize', token_endpoint: 'https://acme.auth0.com/t' } })],
337+
[(u) => /accounts\.google\.com\/.well-known/.test(u), resp({ json: { authorization_endpoint: 'https://accounts.google.com/auth', token_endpoint: 'https://t' } })],
338+
]),
339+
});
340+
const app = createApp(e);
341+
expect((await app.loadIdps()).idps).toHaveLength(2);
342+
await app.actions.login('auth0');
343+
expect(loc.href).toContain('https://acme.auth0.com/authorize?');
344+
expect(loc.href).toContain('client_id=a');
345+
expect(e.sessionStorage.getItem('oauth_idp')).toBe('auth0');
346+
app.signOut();
347+
expect(e.sessionStorage.getItem('oauth_idp')).toBeNull(); // cleared on sign-out
348+
});
326349
it('refresh succeeds via the ClickHouse context', async () => {
327350
const e = env({
328351
sessionStorage: memSession({ oauth_id_token: jwt({ exp: 1 }), oauth_refresh_token: 'rt' }),

tests/unit/login.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,33 @@ describe('renderLogin', () => {
4848
await tick();
4949
expect(showLogin).toHaveBeenCalledWith('rawstr');
5050
});
51+
52+
it('multiple IdPs → one button per provider, clicking passes the IdP id', async () => {
53+
const login = vi.fn(async () => {});
54+
const app = makeApp({
55+
actions: { ...makeApp().actions, login },
56+
loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }, { id: 'a', label: 'Acme SSO' }] }),
57+
});
58+
renderLogin(app);
59+
await tick();
60+
const btns = [...app.root.querySelectorAll('.login-btn')];
61+
expect(btns.map((b) => b.textContent)).toEqual(['Sign in with Google', 'Sign in with Acme SSO']);
62+
btns[1].dispatchEvent(new Event('click'));
63+
await tick();
64+
expect(login).toHaveBeenCalledWith('a');
65+
});
66+
it('a single IdP keeps the lone Sign in button', async () => {
67+
const app = makeApp({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }] }) });
68+
renderLogin(app);
69+
await tick();
70+
const btns = [...app.root.querySelectorAll('.login-btn')];
71+
expect(btns).toHaveLength(1);
72+
expect(btns[0].textContent).toBe('Sign in');
73+
});
74+
it('keeps the single button when the IdP list fails to load', async () => {
75+
const app = makeApp({ loadIdps: async () => { throw new Error('no config'); } });
76+
renderLogin(app);
77+
await tick();
78+
expect(app.root.querySelectorAll('.login-btn')).toHaveLength(1);
79+
});
5180
});

0 commit comments

Comments
 (0)