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 }