|
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. |
4 | 5 | // |
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 | +} |
7 | 42 |
|
8 | 43 | /** |
| 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. |
9 | 47 | * @param {(url: string, init?: object) => Promise<Response>} fetchFn |
10 | 48 | * @param {string} basePath e.g. location.pathname ('/sql') |
11 | | - * @returns {Promise<{clientId,clientSecret,audience,authUri,tokenUri,issuer}>} |
12 | 49 | */ |
13 | | -export async function loadOAuthConfig(fetchFn, basePath = '') { |
| 50 | +export async function loadConfigDoc(fetchFn, basePath = '') { |
14 | 51 | const cfgUrl = basePath.replace(/\/$/, '') + '/config.json'; |
15 | 52 | const cfgResp = await fetchFn(cfgUrl, { cache: 'no-store' }); |
16 | 53 | if (!cfgResp.ok) throw new Error('GET ' + cfgUrl + ': ' + cfgResp.status); |
17 | 54 | 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'; |
22 | 66 | const discResp = await fetchFn(discUrl, { cache: 'no-store' }); |
23 | 67 | if (!discResp.ok) throw new Error('OIDC discovery failed: ' + discResp.status); |
24 | 68 | const disc = await discResp.json(); |
25 | 69 | if (!disc.authorization_endpoint || !disc.token_endpoint) { |
26 | 70 | throw new Error('OIDC discovery missing authorization_endpoint or token_endpoint'); |
27 | 71 | } |
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 }; |
49 | 73 | } |
50 | 74 |
|
51 | 75 | /** |
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 |
53 | 77 | * with the same signature; a failed load is not cached (so a retry re-fetches). |
54 | 78 | */ |
55 | 79 | export function memoizeConfig(loader) { |
|
0 commit comments