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 }