diff --git a/README.md b/README.md index e9cf2b3..5d17eec 100644 --- a/README.md +++ b/README.md @@ -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 +`` 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 diff --git a/deploy/config.json.example b/deploy/config.json.example index c4afb4c..55ce227 100644 --- a/deploy/config.json.example +++ b/deploy/config.json.example @@ -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" + } + ] } diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js index f13d503..fd71532 100644 --- a/src/net/oauth-config.js +++ b/src/net/oauth-config.js @@ -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 + // ) 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} 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 , 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) { diff --git a/src/styles.css b/src/styles.css index c18a59d..721c83e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/ui/app.js b/src/ui/app.js index d463b77..2bb2135 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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; @@ -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); @@ -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; @@ -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 { @@ -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, diff --git a/src/ui/login.js b/src/ui/login.js index 2455850..ef668e4 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -1,15 +1,40 @@ -// 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' }, @@ -17,24 +42,20 @@ export function renderLogin(app, errorMsg) { 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 */ }); + } } diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index d13214a..5090f76 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -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' }), diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index ae5d713..0e1c48c 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -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); + }); }); diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js index 97f549b..7661dfe 100644 --- a/tests/unit/oauth-config.test.js +++ b/tests/unit/oauth-config.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { loadOAuthConfig, memoizeConfig } from '../../src/net/oauth-config.js'; +import { loadConfigDoc, resolveIdp, memoizeConfig } from '../../src/net/oauth-config.js'; const resp = (ok, body, status = ok ? 200 : 500) => ({ ok, @@ -14,94 +14,95 @@ function fetcher(map) { }); } -const okConfig = { issuer: 'https://accounts.google.com', client_id: 'cid', client_secret: 'sek', audience: 'aud' }; const okDisc = { authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', token_endpoint: 'https://oauth2.googleapis.com/token', }; -describe('loadOAuthConfig', () => { - it('resolves config + discovery', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, okConfig)], - [/openid-configuration$/, resp(true, okDisc)], - ]); - const cfg = await loadOAuthConfig(f, '/sql'); - expect(cfg).toEqual({ - issuer: 'https://accounts.google.com', - clientId: 'cid', - clientSecret: 'sek', - audience: 'aud', - authUri: okDisc.authorization_endpoint, - tokenUri: okDisc.token_endpoint, - bearer: 'id_token', - chAuth: 'bearer', - authorizeParams: {}, - }); +describe('loadConfigDoc', () => { + const docOf = async (body, base = '/sql') => + (await loadConfigDoc(fetcher([[/config\.json$/, resp(true, body)]]), base)).idps; + + it('wraps a single bare-object config into one IdP (host id/label defaults)', async () => { + const f = fetcher([[/config\.json$/, resp(true, { + issuer: 'https://accounts.google.com', client_id: 'cid', client_secret: 'sek', audience: 'aud', + })]]); + const { idps } = await loadConfigDoc(f, '/sql'); + expect(idps).toEqual([{ + id: 'accounts.google.com', label: 'accounts.google.com', + issuer: 'https://accounts.google.com', clientId: 'cid', clientSecret: 'sek', + audience: 'aud', bearer: 'id_token', chAuth: 'bearer', authorizeParams: {}, + }]); expect(f.mock.calls[0][0]).toBe('/sql/config.json'); }); - it('defaults clientSecret/audience/bearer/authorizeParams', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, { issuer: 'https://i', client_id: 'c' })], - [/openid-configuration$/, resp(true, okDisc)], + it('parses a list and honours explicit id/label', async () => { + const idps = await docOf({ idps: [ + { id: 'g', label: 'Google', issuer: 'https://accounts.google.com', client_id: 'c1' }, + { id: 'a', label: 'Acme', issuer: 'https://acme.auth0.com', client_id: 'c2', bearer: 'access_token' }, + ] }); + expect(idps.map((i) => [i.id, i.label, i.bearer])).toEqual([ + ['g', 'Google', 'id_token'], ['a', 'Acme', 'access_token'], ]); - const cfg = await loadOAuthConfig(f, ''); - expect(cfg.clientSecret).toBe(''); - expect(cfg.audience).toBe(''); - expect(cfg.bearer).toBe('id_token'); - expect(cfg.chAuth).toBe('bearer'); - expect(cfg.authorizeParams).toEqual({}); }); - it('honours ch_auth=basic', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, { issuer: 'https://i', client_id: 'c', ch_auth: 'basic' })], - [/openid-configuration$/, resp(true, okDisc)], - ]); - const cfg = await loadOAuthConfig(f, ''); - expect(cfg.chAuth).toBe('basic'); + it('defaults id/label to the issuer host, and to the raw string for a non-URL issuer', async () => { + const idps = await docOf({ idps: [ + { issuer: 'https://acme.auth0.com', client_id: 'c' }, + { issuer: 'weird', client_id: 'c' }, + ] }); + expect(idps[0].id).toBe('acme.auth0.com'); + expect(idps[1].id).toBe('weird'); // new URL('weird') throws → raw fallback }); - it('honours bearer=access_token and authorize_params object', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, { - issuer: 'https://i', client_id: 'c', bearer: 'access_token', - authorize_params: { organization: 'org_x' }, - })], - [/openid-configuration$/, resp(true, okDisc)], - ]); - const cfg = await loadOAuthConfig(f, ''); - expect(cfg.bearer).toBe('access_token'); - expect(cfg.authorizeParams).toEqual({ organization: 'org_x' }); + it('defaults clientSecret/audience/bearer/chAuth/authorizeParams', async () => { + const [idp] = await docOf({ issuer: 'https://i', client_id: 'c' }); + expect(idp.clientSecret).toBe(''); + expect(idp.audience).toBe(''); + expect(idp.bearer).toBe('id_token'); + expect(idp.chAuth).toBe('bearer'); + expect(idp.authorizeParams).toEqual({}); + }); + it('honours ch_auth=basic, bearer=access_token, and an authorize_params object', async () => { + const [idp] = await docOf({ + issuer: 'https://i', client_id: 'c', ch_auth: 'basic', bearer: 'access_token', + authorize_params: { organization: 'org_x' }, + }); + expect(idp.chAuth).toBe('basic'); + expect(idp.bearer).toBe('access_token'); + expect(idp.authorizeParams).toEqual({ organization: 'org_x' }); }); it('ignores a non-object authorize_params and an unknown bearer', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, { issuer: 'https://i', client_id: 'c', bearer: 'weird', authorize_params: 'nope' })], - [/openid-configuration$/, resp(true, okDisc)], - ]); - const cfg = await loadOAuthConfig(f, ''); - expect(cfg.bearer).toBe('id_token'); - expect(cfg.authorizeParams).toEqual({}); + const [idp] = await docOf({ issuer: 'https://i', client_id: 'c', bearer: 'weird', authorize_params: 'nope' }); + expect(idp.bearer).toBe('id_token'); + expect(idp.authorizeParams).toEqual({}); }); it('throws when config.json is not ok', async () => { const f = fetcher([[/config\.json$/, resp(false, null, 404)]]); - await expect(loadOAuthConfig(f, '/sql')).rejects.toThrow('config.json: 404'); + await expect(loadConfigDoc(f, '/sql')).rejects.toThrow('config.json: 404'); + }); + it('throws when an IdP lacks issuer/client_id', async () => { + await expect(docOf({ issuer: 'x' })).rejects.toThrow('missing issuer or client_id'); }); - it('throws when config.json lacks issuer/client_id', async () => { - const f = fetcher([[/config\.json$/, resp(true, { issuer: 'x' })]]); - await expect(loadOAuthConfig(f, '/sql')).rejects.toThrow('missing issuer or client_id'); + it('throws when the idps list is empty', async () => { + await expect(docOf({ idps: [] })).rejects.toThrow('no IdPs'); + }); +}); + +describe('resolveIdp', () => { + const idp = { + id: 'i', label: 'I', issuer: 'https://i', clientId: 'c', clientSecret: '', + audience: '', bearer: 'id_token', chAuth: 'bearer', authorizeParams: {}, + }; + it('adds authUri/tokenUri from OIDC discovery, preserving the descriptor', async () => { + const f = fetcher([[/openid-configuration$/, resp(true, okDisc)]]); + const cfg = await resolveIdp(f, idp); + expect(cfg).toEqual({ ...idp, authUri: okDisc.authorization_endpoint, tokenUri: okDisc.token_endpoint }); }); it('throws when discovery is not ok', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, okConfig)], - [/openid-configuration$/, resp(false, null, 500)], - ]); - await expect(loadOAuthConfig(f, '/sql')).rejects.toThrow('OIDC discovery failed: 500'); + const f = fetcher([[/openid-configuration$/, resp(false, null, 500)]]); + await expect(resolveIdp(f, idp)).rejects.toThrow('OIDC discovery failed: 500'); }); it('throws when discovery lacks endpoints', async () => { - const f = fetcher([ - [/config\.json$/, resp(true, okConfig)], - [/openid-configuration$/, resp(true, { authorization_endpoint: 'a' })], - ]); - await expect(loadOAuthConfig(f, '/sql')).rejects.toThrow('missing authorization_endpoint or token_endpoint'); + const f = fetcher([[/openid-configuration$/, resp(true, { authorization_endpoint: 'a' })]]); + await expect(resolveIdp(f, idp)).rejects.toThrow('missing authorization_endpoint or token_endpoint'); }); });