conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

oidc.js (7035B)


      1 // src/lib/auth/oidc.js - OIDC discovery, browser login and token verification
      2 //
      3 // Engaged when auth.oidc.discovery_url is configured. The provider's OpenID
      4 // configuration document owns every detail of the provider: its issuer, the
      5 // authorization and token endpoints, the key set and the end session
      6 // endpoint. A Keycloak realm serves them under /protocol/openid-connect/...,
      7 // but that is a Keycloak detail and nothing else follows it; guessing it
      8 // works with one provider and silently fails with every other.
      9 //
     10 // The browser signs in through the authorization code flow and the session
     11 // cookie is the id_token the provider issued. Every request re-verifies it
     12 // against the discovered issuer and key set, so nothing is re-signed here.
     13 //
     14 // Role mapping deliberately looks in several places. Keycloak puts realm
     15 // roles under realm_access.roles, other providers use a flat roles claim or
     16 // groups, and there is no standard.
     17 
     18 const ADMIN = 'admin';
     19 const USER_ROLE = 'user';
     20 const DISCOVERY_TIMEOUT = 10000;
     21 const TOKEN_TIMEOUT = 10000;
     22 
     23 export function collectRoles(claims) {
     24   const roles = new Set();
     25   const add = (value) => {
     26     if (typeof value === 'string') roles.add(value);
     27     else if (Array.isArray(value)) for (const item of value) if (typeof item === 'string') roles.add(item);
     28   };
     29 
     30   add(claims.roles);
     31   add(claims.groups);
     32   add(claims.realm_access?.roles);
     33   for (const resource of Object.values(claims.resource_access ?? {})) add(resource?.roles);
     34 
     35   return [...roles];
     36 }
     37 
     38 // Fetches and validates the provider's configuration document. The document
     39 // is the single source of truth for the issuer and every endpoint.
     40 export async function fetchDiscovery(url, { fetchImpl = fetch } = {}) {
     41   let res;
     42   try {
     43     res = await fetchImpl(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT) });
     44   } catch (e) {
     45     throw new Error(`could not reach the OIDC discovery document at ${url}: ${e.message}`, { cause: e });
     46   }
     47   if (!res.ok) {
     48     throw new Error(`OIDC discovery failed: ${url} returned ${res.status} ${res.statusText}`);
     49   }
     50 
     51   let document;
     52   try {
     53     document = await res.json();
     54   } catch {
     55     throw new Error(`OIDC discovery at ${url} did not return JSON`);
     56   }
     57 
     58   for (const field of ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
     59     if (typeof document?.[field] !== 'string' || document[field].length === 0) {
     60       throw new Error(`OIDC discovery at ${url} did not advertise ${field}`);
     61     }
     62   }
     63 
     64   return document;
     65 }
     66 
     67 export function createOidc(cfg) {
     68   const {
     69     discovery_url: discoveryUrl,
     70     client_id: clientId,
     71     client_secret: clientSecret,
     72     scopes,
     73     admin_role: adminRole,
     74   } = cfg.auth.oidc;
     75 
     76   let metadata = null;
     77   let jwks = null;
     78   let jwtVerify = null;
     79   let loading = null;
     80 
     81   async function load() {
     82     if (metadata) return metadata;
     83     // Concurrent requests during startup should discover once, not once
     84     // each, and a failure must not be cached.
     85     if (!loading) {
     86       loading = (async () => {
     87         let jose;
     88         try {
     89           jose = await import('jose');
     90         } catch (e) {
     91           throw new Error('auth.oidc.discovery_url is set but the jose package is not installed', { cause: e });
     92         }
     93         const document = await fetchDiscovery(discoveryUrl);
     94         jwtVerify = jose.jwtVerify;
     95         jwks = jose.createRemoteJWKSet(new URL(document.jwks_uri));
     96         metadata = document;
     97         return metadata;
     98       })().finally(() => { loading = null; });
     99     }
    100     return loading;
    101   }
    102 
    103   function profileFrom(claims, issuer) {
    104     const roles = collectRoles(claims);
    105     return {
    106       issuer,
    107       subject: claims.sub,
    108       username: claims.preferred_username ?? claims.email ?? claims.sub,
    109       role: roles.includes(adminRole) ? ADMIN : USER_ROLE,
    110       roles,
    111     };
    112   }
    113 
    114   return {
    115     mode: 'oidc',
    116 
    117     // The configuration document, fetched once.
    118     async metadata() {
    119       return load();
    120     },
    121 
    122     // Verifies the provider's token: signature, issuer and, for the
    123     // id_token the browser presents, the client it was issued to.
    124     async verify(token) {
    125       const document = await load();
    126       const { payload } = await jwtVerify(token, jwks, {
    127         issuer: document.issuer,
    128         audience: clientId,
    129       });
    130       return profileFrom(payload, document.issuer);
    131     },
    132 
    133     // Where to send the browser to sign in.
    134     async authorizationUrl({ redirectUri, state }) {
    135       const document = await load();
    136       const url = new URL(document.authorization_endpoint);
    137       url.searchParams.set('response_type', 'code');
    138       url.searchParams.set('client_id', clientId);
    139       url.searchParams.set('redirect_uri', redirectUri);
    140       url.searchParams.set('scope', scopes);
    141       url.searchParams.set('state', state);
    142       return url.toString();
    143     },
    144 
    145     // Redeems the authorization code at the discovered token endpoint. A
    146     // confidential client authenticates with HTTP Basic.
    147     async exchangeCode({ code, redirectUri }) {
    148       const document = await load();
    149       const headers = { 'content-type': 'application/x-www-form-urlencoded' };
    150       if (clientSecret) {
    151         headers.authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
    152       }
    153 
    154       let res;
    155       try {
    156         res = await fetch(document.token_endpoint, {
    157           method: 'POST',
    158           headers,
    159           body: new URLSearchParams({
    160             grant_type: 'authorization_code',
    161             code,
    162             redirect_uri: redirectUri,
    163             client_id: clientId,
    164           }),
    165           signal: AbortSignal.timeout(TOKEN_TIMEOUT),
    166         });
    167       } catch (e) {
    168         throw new Error(
    169           `could not reach the OIDC token endpoint at ${document.token_endpoint}: ${e.message}`,
    170           { cause: e }
    171         );
    172       }
    173 
    174       const text = await res.text();
    175       let payload = null;
    176       try {
    177         payload = JSON.parse(text);
    178       } catch {
    179         // Reported below, together with the status.
    180       }
    181 
    182       if (!res.ok) {
    183         const detail = payload?.error_description ?? payload?.error ?? text;
    184         throw new Error(
    185           `OIDC token exchange failed: ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`
    186         );
    187       }
    188       if (payload === null || typeof payload !== 'object') {
    189         throw new Error('OIDC token endpoint did not return JSON');
    190       }
    191       return payload;
    192     },
    193 
    194     // Where to send the browser to end the provider session, or null when
    195     // the provider does not advertise one.
    196     async endSessionUrl({ postLogoutRedirectUri } = {}) {
    197       const document = await load();
    198       if (typeof document.end_session_endpoint !== 'string' || document.end_session_endpoint.length === 0) {
    199         return null;
    200       }
    201       const url = new URL(document.end_session_endpoint);
    202       url.searchParams.set('client_id', clientId);
    203       if (postLogoutRedirectUri) url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
    204       return url.toString();
    205     },
    206   };
    207 }