conductor

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

oidc.js (8144B)


      1 // src/lib/auth/oidc.js - discovery and access token verification
      2 //
      3 // The app runs the code flow against the provider itself. All that happens
      4 // here is verifying the tokens it comes back with.
      5 
      6 const ADMIN = 'admin';
      7 const USER_ROLE = 'user';
      8 const DISCOVERY_TIMEOUT = 10000;
      9 
     10 function isCompactJws(token) {
     11   return typeof token === 'string' && token.split('.').length === 3;
     12 }
     13 
     14 export function collectRoles(claims) {
     15   const roles = new Set();
     16   const add = (value) => {
     17     if (typeof value === 'string') roles.add(value);
     18     else if (Array.isArray(value)) for (const item of value) if (typeof item === 'string') roles.add(item);
     19   };
     20 
     21   add(claims.roles);
     22   add(claims.groups);
     23   add(claims.realm_access?.roles);
     24   for (const resource of Object.values(claims.resource_access ?? {})) add(resource?.roles);
     25 
     26   return [...roles];
     27 }
     28 
     29 // Fetches and validates the provider's configuration document. The document
     30 // is the single source of truth for the issuer and every endpoint.
     31 export async function fetchDiscovery(url, { fetchImpl = fetch } = {}) {
     32   let res;
     33   try {
     34     res = await fetchImpl(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT) });
     35   } catch (e) {
     36     throw new Error(`could not reach the OIDC discovery document at ${url}: ${e.message}`, { cause: e });
     37   }
     38   if (!res.ok) {
     39     throw new Error(`OIDC discovery failed: ${url} returned ${res.status} ${res.statusText}`);
     40   }
     41 
     42   let document;
     43   try {
     44     document = await res.json();
     45   } catch {
     46     throw new Error(`OIDC discovery at ${url} did not return JSON`);
     47   }
     48 
     49   for (const field of ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
     50     if (typeof document?.[field] !== 'string' || document[field].length === 0) {
     51       throw new Error(`OIDC discovery at ${url} did not advertise ${field}`);
     52     }
     53   }
     54 
     55   return document;
     56 }
     57 
     58 export function createOidc(cfg, options = {}) {
     59   const fetchImpl = options.fetchImpl ?? cfg?.fetchImpl ?? fetch;
     60   const {
     61     discovery_url: discoveryUrl,
     62     client_id: clientId,
     63     client_secret: clientSecret,
     64     scopes,
     65     admin_role: adminRole,
     66   } = cfg.auth.oidc;
     67 
     68   let metadata = null;
     69   let jwks = null;
     70   let jwtVerify = null;
     71   let loading = null;
     72 
     73   async function load() {
     74     if (metadata) return metadata;
     75     // Concurrent requests during startup should discover once, not once
     76     // each, and a failure must not be cached.
     77     if (!loading) {
     78       loading = (async () => {
     79         let jose;
     80         try {
     81           jose = await import('jose');
     82         } catch (e) {
     83           throw new Error('auth.oidc.discovery_url is set but the jose package is not installed', { cause: e });
     84         }
     85         const document = await fetchDiscovery(discoveryUrl, { fetchImpl });
     86         jwtVerify = jose.jwtVerify;
     87         jwks = jose.createRemoteJWKSet(new URL(document.jwks_uri));
     88         metadata = document;
     89         return metadata;
     90       })().finally(() => { loading = null; });
     91     }
     92     return loading;
     93   }
     94 
     95   function profileFrom(claims, issuer) {
     96     const roles = collectRoles(claims);
     97     return {
     98       issuer,
     99       subject: claims.sub,
    100       username: claims.preferred_username ?? claims.email ?? claims.sub,
    101       role: roles.includes(adminRole) ? ADMIN : USER_ROLE,
    102       roles,
    103     };
    104   }
    105 
    106   function checkAudience(claims) {
    107     const azp = claims.azp ?? claims.client_id;
    108     const audRaw = claims.aud;
    109     const aud = audRaw == null ? [] : Array.isArray(audRaw) ? audRaw : [audRaw];
    110     if (azp === undefined && aud.length === 0) return;
    111     if (azp !== clientId && !aud.includes(clientId)) {
    112       throw new Error(`token was issued to ${azp ?? aud.join(', ')}, not ${clientId}`);
    113     }
    114   }
    115 
    116   async function verifyJwt(token, document) {
    117     const { payload } = await jwtVerify(token, jwks, { issuer: document.issuer });
    118     const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
    119     if (payload.azp !== clientId && !aud.includes(clientId)) {
    120       throw new Error(`token was issued to ${payload.azp ?? aud.join(', ')}, not ${clientId}`);
    121     }
    122     return profileFrom(payload, document.issuer);
    123   }
    124 
    125   async function introspect(token, endpoint, issuer) {
    126     const headers = { 'content-type': 'application/x-www-form-urlencoded' };
    127     const body = new URLSearchParams();
    128     body.set('token', token);
    129     body.set('client_id', clientId);
    130     if (clientSecret) body.set('client_secret', clientSecret);
    131     if (clientSecret) {
    132       headers.authorization = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
    133     }
    134 
    135     let res;
    136     try {
    137       res = await fetchImpl(endpoint, { method: 'POST', headers, body });
    138     } catch (e) {
    139       throw new Error(`could not reach the OIDC introspection endpoint at ${endpoint}: ${e.message}`, { cause: e });
    140     }
    141 
    142     const text = await res.text();
    143     let data = null;
    144     try { data = text ? JSON.parse(text) : null; } catch { data = null; }
    145 
    146     if (!res.ok) {
    147       if (!clientSecret && /invalid_client/i.test(text)) {
    148         return null;
    149       }
    150       throw new Error(`OIDC introspection failed: ${endpoint} returned ${res.status} ${res.statusText}`);
    151     }
    152 
    153     if (!data || data.active !== true) {
    154       if (data && data.active === false) throw new Error('token is not active');
    155       throw new Error('OIDC introspection did not confirm the token is active');
    156     }
    157 
    158     return data;
    159   }
    160 
    161   async function fetchUserinfo(token, endpoint) {
    162     let res;
    163     try {
    164       res = await fetchImpl(endpoint, {
    165         headers: { authorization: `Bearer ${token}` },
    166       });
    167     } catch (e) {
    168       throw new Error(`could not reach the OIDC userinfo endpoint at ${endpoint}: ${e.message}`, { cause: e });
    169     }
    170 
    171     if (!res.ok) {
    172       if (res.status === 401 || res.status === 403) throw new Error('token is not active');
    173       throw new Error(`OIDC userinfo failed: ${endpoint} returned ${res.status} ${res.statusText}`);
    174     }
    175 
    176     let data;
    177     try {
    178       data = await res.json();
    179     } catch {
    180       throw new Error(`OIDC userinfo at ${endpoint} did not return JSON`);
    181     }
    182 
    183     if (!data || typeof data.sub !== 'string' || data.sub.length === 0) {
    184       throw new Error('OIDC userinfo did not include a subject');
    185     }
    186 
    187     return data;
    188   }
    189 
    190   return {
    191     mode: 'oidc',
    192 
    193     // The configuration document, fetched once.
    194     async metadata() {
    195       return load();
    196     },
    197 
    198     clientId,
    199     scopes,
    200 
    201     async verify(token) {
    202       const document = await load();
    203       const introspectionEndpoint = typeof document.introspection_endpoint === 'string' && document.introspection_endpoint.length > 0
    204         ? document.introspection_endpoint
    205         : null;
    206       const userinfoEndpoint = typeof document.userinfo_endpoint === 'string' && document.userinfo_endpoint.length > 0
    207         ? document.userinfo_endpoint
    208         : null;
    209 
    210       let introspectionData = null;
    211       let introspectionFailed = false;
    212 
    213       if (introspectionEndpoint) {
    214         try {
    215           introspectionData = await introspect(token, introspectionEndpoint);
    216         } catch (e) {
    217           if (e.message === 'token is not active' || /did not confirm/i.test(e.message)) throw e;
    218           introspectionFailed = true;
    219         }
    220       }
    221 
    222       let userinfoData = null;
    223       if (userinfoEndpoint) {
    224         try {
    225           userinfoData = await fetchUserinfo(token, userinfoEndpoint);
    226         } catch (e) {
    227           if (introspectionData) throw e;
    228           if (introspectionEndpoint && !introspectionFailed && introspectionData === null && isCompactJws(token) && jwtVerify && jwks) {
    229             return verifyJwt(token, document);
    230           }
    231           throw e;
    232         }
    233       }
    234 
    235       const merged = { ...(introspectionData || {}), ...(userinfoData || {}) };
    236 
    237       if (merged.sub) {
    238         checkAudience(merged);
    239         return profileFrom(merged, document.issuer);
    240       }
    241 
    242       if (isCompactJws(token) && jwtVerify && jwks) {
    243         return verifyJwt(token, document);
    244       }
    245 
    246       if (introspectionData === null && userinfoData === null && !introspectionFailed) {
    247         throw new Error('OIDC provider did not verify the token');
    248       }
    249       throw new Error('OIDC response did not include a subject');
    250     },
    251   };
    252 }