UNPKG

@axa-fr/oidc-client-service-worker

Version:

OpenID Connect & OAuth authentication service worker

621 lines (558 loc) 22.9 kB
import { acceptAnyDomainToken, scriptFilename, TOKEN } from './constants'; import { base64urlOfHashOfASCIIEncodingAsync } from './crypto'; import { getDpopConfiguration, getDpopOnlyWhenDpopHeaderPresent } from './dpop'; import { generateJwkAsync, generateJwtDemonstratingProofOfPossessionAsync } from './jwt'; import { getCurrentDatabasesTokenEndpoint } from './oidcConfig'; import { Database, MessageEventData, OidcConfig, TrustedDomains } from './types'; import { checkDomain, getCurrentDatabaseDomain, getDomains, hideTokens, isTokensValid, normalizeUrl, serializeHeaders, sleep, } from './utils'; import { extractConfigurationNameFromCodeVerifier, replaceCodeVerifier, } from './utils/codeVerifier'; import version from './version'; // @ts-ignore if (typeof trustedTypes !== 'undefined' && typeof trustedTypes.createPolicy === 'function') { // @ts-ignore trustedTypes.createPolicy('default', { createScriptURL: function (url: string) { if (url === scriptFilename) { return url; } else { throw new Error('Untrusted script URL blocked: ' + url); } }, }); } const _self = self as ServiceWorkerGlobalScope & typeof globalThis; // Déclare `trustedDomains` qui vient de l'extérieur : declare let trustedDomains: TrustedDomains; _self.importScripts(scriptFilename); const id = Math.round(new Date().getTime() / 1000).toString(); console.log('init service worker with id', id); const keepAliveJsonFilename = 'OidcKeepAliveServiceWorker.json'; const database: Database = {}; /** * Routine keepAlive : renvoie une réponse après un "sleep" éventuel. */ const keepAliveAsync = async (event: FetchEvent) => { const originalRequest = event.request; const isFromVanilla = originalRequest.headers.has('oidc-vanilla'); const init = { status: 200, statusText: 'oidc-service-worker' }; const response = new Response('{}', init); if (!isFromVanilla) { const originalRequestUrl = new URL(originalRequest.url); const minSleepSeconds = Number(originalRequestUrl.searchParams.get('minSleepSeconds')) || 240; for (let i = 0; i < minSleepSeconds; i++) { await sleep(1000 + Math.floor(Math.random() * 1000)); const cache = await caches.open('oidc_dummy_cache'); await cache.put(event.request, response.clone()); } } return response; }; /** * Génération d'en-têtes DPoP s'il y a configuration dpop. */ async function generateDpopAsync( originalRequest: Request, currentDatabase: OidcConfig | null, url: string, extrasClaims = {}, ) { const headersExtras = serializeHeaders(originalRequest.headers); if ( currentDatabase?.demonstratingProofOfPossessionConfiguration && currentDatabase.demonstratingProofOfPossessionJwkJson && (!currentDatabase.demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent || (currentDatabase.demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent && headersExtras.dpop)) ) { const dpopConfiguration = currentDatabase.demonstratingProofOfPossessionConfiguration; const jwk = currentDatabase.demonstratingProofOfPossessionJwkJson; const method = originalRequest.method; const dpop = await generateJwtDemonstratingProofOfPossessionAsync(self)(dpopConfiguration)( jwk, method, url, extrasClaims, ); headersExtras.dpop = dpop; if (currentDatabase.demonstratingProofOfPossessionNonce != null) { headersExtras.nonce = currentDatabase.demonstratingProofOfPossessionNonce; } } return headersExtras; } /** * Nouveau handleFetch : on n’est plus async "directement". * On encapsule toute la logique dans un `respondWith((async () => { ... })())`. */ const handleFetch = (event: FetchEvent): void => { event.respondWith( (async (): Promise<Response> => { try { const originalRequest = event.request; const url = normalizeUrl(originalRequest.url); // 1) Si on est sur la ressource KeepAlive if (url.includes(keepAliveJsonFilename)) { return keepAliveAsync(event); } // 2) Cas normal : on regarde si on a un token const currentDatabasesForRequestAccessToken = getCurrentDatabaseDomain( database, url, trustedDomains, ); const authorization = originalRequest.headers.get('authorization'); let authenticationMode = 'Bearer'; let key = 'default'; if (authorization) { const split = authorization.split(' '); authenticationMode = split[0]; if (split[1]?.includes('ACCESS_TOKEN_SECURED_BY_OIDC_SERVICE_WORKER_')) { key = split[1].split('ACCESS_TOKEN_SECURED_BY_OIDC_SERVICE_WORKER_')[1]; } } const currentDatabaseForRequestAccessToken = currentDatabasesForRequestAccessToken?.find( c => c.configurationName.endsWith(key), ); // 2a) Si on a déjà des tokens valides if (currentDatabaseForRequestAccessToken?.tokens?.access_token) { // On attend que le token soit valide (refresh possible en parallèle) while ( currentDatabaseForRequestAccessToken.tokens && !isTokensValid(currentDatabaseForRequestAccessToken.tokens) ) { await sleep(200); } // Ajustement du mode let requestMode = originalRequest.mode; if ( originalRequest.mode !== 'navigate' && currentDatabaseForRequestAccessToken.convertAllRequestsToCorsExceptNavigate ) { requestMode = 'cors'; } // Construction des en-têtes let headers: { [p: string]: string }; // Pas de token sur la requête "navigate" si setAccessTokenToNavigateRequests = false if ( originalRequest.mode === 'navigate' && !currentDatabaseForRequestAccessToken.setAccessTokenToNavigateRequests ) { headers = { ...serializeHeaders(originalRequest.headers), }; } else { // On injecte le token if ( authenticationMode.toLowerCase() === 'dpop' || (!currentDatabaseForRequestAccessToken.demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent && currentDatabaseForRequestAccessToken.demonstratingProofOfPossessionConfiguration) ) { // Mode DPoP const claimsExtras = { ath: await base64urlOfHashOfASCIIEncodingAsync( currentDatabaseForRequestAccessToken.tokens.access_token, ), }; const dpopHeaders = await generateDpopAsync( originalRequest, currentDatabaseForRequestAccessToken, url, claimsExtras, ); headers = { ...dpopHeaders, authorization: `DPoP ${currentDatabaseForRequestAccessToken.tokens.access_token}`, }; } else { // Mode Bearer headers = { ...serializeHeaders(originalRequest.headers), authorization: `${authenticationMode} ${currentDatabaseForRequestAccessToken.tokens.access_token}`, }; } } let init: RequestInit; if (originalRequest.mode === 'navigate') { init = { headers: headers, }; } else { init = { headers: headers, mode: requestMode, }; } const newRequest = new Request(originalRequest, init); return fetch(newRequest); } // 3) S’il ne s’agit pas d’un POST => on laisse passer if (event.request.method !== 'POST') { return fetch(originalRequest); } // 4) Cas POST vers un endpoint connu (token, revocation) const currentDatabases = getCurrentDatabasesTokenEndpoint(database, url); const numberDatabase = currentDatabases.length; if (numberDatabase > 0) { // On gère tout dans une promesse const responsePromise = new Promise<Response>((resolve, reject) => { const clonedRequest = originalRequest.clone(); clonedRequest .text() .then(async actualBody => { let currentDatabase: OidcConfig | null = null; try { // 4a) S’il y a un refresh_token masqué if ( actualBody.includes(TOKEN.REFRESH_TOKEN) || actualBody.includes(TOKEN.ACCESS_TOKEN) ) { let headers = serializeHeaders(originalRequest.headers); let newBody = actualBody; for (let i = 0; i < numberDatabase; i++) { const currentDb = currentDatabases[i]; if (currentDb?.tokens) { const claimsExtras = { ath: await base64urlOfHashOfASCIIEncodingAsync( currentDb.tokens.access_token, ), }; headers = await generateDpopAsync( originalRequest, currentDb, url, claimsExtras, ); const keyRefreshToken = encodeURIComponent( `${TOKEN.REFRESH_TOKEN}_${currentDb.configurationName}`, ); if (actualBody.includes(keyRefreshToken)) { newBody = newBody.replace( keyRefreshToken, encodeURIComponent(currentDb.tokens.refresh_token as string), ); currentDatabase = currentDb; break; } const keyAccessToken = encodeURIComponent( `${TOKEN.ACCESS_TOKEN}_${currentDb.configurationName}`, ); if (actualBody.includes(keyAccessToken)) { newBody = newBody.replace( keyAccessToken, encodeURIComponent(currentDb.tokens.access_token), ); currentDatabase = currentDb; break; } } } const fetchPromise = fetch(originalRequest, { body: newBody, method: clonedRequest.method, headers, mode: clonedRequest.mode, cache: clonedRequest.cache, redirect: clonedRequest.redirect, referrer: clonedRequest.referrer, credentials: clonedRequest.credentials, integrity: clonedRequest.integrity, }); // Cas “revocationEndpoint” ? if ( currentDatabase?.oidcServerConfiguration?.revocationEndpoint && url.startsWith( normalizeUrl(currentDatabase.oidcServerConfiguration.revocationEndpoint), ) ) { // On ne modifie pas le corps const resp = await fetchPromise; const txt = await resp.text(); resolve(new Response(txt, resp)); return; } // Sinon on “cache” les tokens dans la réponse const hidden = await fetchPromise.then( hideTokens(currentDatabase as OidcConfig), ); resolve(hidden); return; } // 4b) Sinon si c’est le code_verifier const isCodeVerifier = actualBody.includes('code_verifier='); if (isCodeVerifier) { const currentLoginCallbackConfigurationName = extractConfigurationNameFromCodeVerifier(actualBody); if ( !currentLoginCallbackConfigurationName || currentLoginCallbackConfigurationName === '' ) { throw new Error('No configuration name found in code_verifier'); } currentDatabase = database[currentLoginCallbackConfigurationName]; let newBody = actualBody; const codeVerifier = currentDatabase.codeVerifier; if (codeVerifier != null) { newBody = replaceCodeVerifier(newBody, codeVerifier); } const headersExtras = await generateDpopAsync( originalRequest, currentDatabase, url, ); const resp = await fetch(originalRequest, { body: newBody, method: clonedRequest.method, headers: headersExtras, mode: clonedRequest.mode, cache: clonedRequest.cache, redirect: clonedRequest.redirect, referrer: clonedRequest.referrer, credentials: clonedRequest.credentials, integrity: clonedRequest.integrity, }); const hidden = await hideTokens(currentDatabase)(resp); resolve(hidden); return; } // 4c) Sinon on laisse passer tel quel const normalResp = await fetch(originalRequest, { body: actualBody, method: clonedRequest.method, headers: serializeHeaders(originalRequest.headers), mode: clonedRequest.mode, cache: clonedRequest.cache, redirect: clonedRequest.redirect, referrer: clonedRequest.referrer, credentials: clonedRequest.credentials, integrity: clonedRequest.integrity, }); resolve(normalResp); } catch (err) { reject(err); } }) .catch(reject); }); // On renvoie simplement la promesse return responsePromise; } // 5) Par défaut, on laisse passer la requête return fetch(originalRequest); } catch (err) { // En cas d’erreur imprévue, on log et on retourne une 500 console.error('[OidcServiceWorker] handleFetch error:', err); return new Response('Service Worker Error', { status: 500 }); } })(), ); }; // ---- Gestion des messages depuis la page const handleMessage = async (event: ExtendableMessageEvent) => { const port = event.ports[0]; const data = event.data as MessageEventData; if (event.data?.type === 'SKIP_WAITING') { await _self.skipWaiting(); return; } else if (event.data.type === 'claim') { _self.clients.claim().then(() => port.postMessage({})); return; } const configurationName = data.configurationName.split('#')[0]; if (trustedDomains == null) { trustedDomains = {}; } const trustedDomain = trustedDomains[configurationName]; const allowMultiTabLogin = Array.isArray(trustedDomain) ? false : trustedDomain.allowMultiTabLogin; const tabId = allowMultiTabLogin ? data.tabId : 'default'; const configurationNameWithTabId = `${configurationName}#tabId=${tabId}`; let currentDatabase = database[configurationNameWithTabId]; if (!currentDatabase) { const showAccessToken = Array.isArray(trustedDomain) ? false : trustedDomain.showAccessToken; const doNotSetAccessTokenToNavigateRequests = Array.isArray(trustedDomain) ? true : trustedDomain.setAccessTokenToNavigateRequests; const convertAllRequestsToCorsExceptNavigate = Array.isArray(trustedDomain) ? false : trustedDomain.convertAllRequestsToCorsExceptNavigate; database[configurationNameWithTabId] = { tokens: null, state: null, codeVerifier: null, oidcServerConfiguration: null, oidcConfiguration: undefined, nonce: null, status: null, configurationName: configurationNameWithTabId, hideAccessToken: !showAccessToken, setAccessTokenToNavigateRequests: doNotSetAccessTokenToNavigateRequests ?? true, convertAllRequestsToCorsExceptNavigate: convertAllRequestsToCorsExceptNavigate ?? false, demonstratingProofOfPossessionNonce: null, demonstratingProofOfPossessionJwkJson: null, demonstratingProofOfPossessionConfiguration: null, demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent: false, allowMultiTabLogin: allowMultiTabLogin ?? false, }; currentDatabase = database[configurationNameWithTabId]; if (!trustedDomains[configurationName]) { trustedDomains[configurationName] = []; } } switch (data.type) { case 'clear': currentDatabase.tokens = null; currentDatabase.state = null; currentDatabase.codeVerifier = null; currentDatabase.nonce = null; currentDatabase.demonstratingProofOfPossessionNonce = null; currentDatabase.demonstratingProofOfPossessionJwkJson = null; currentDatabase.demonstratingProofOfPossessionConfiguration = null; currentDatabase.demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent = false; currentDatabase.status = data.data.status; port.postMessage({ configurationName }); return; case 'init': { const oidcServerConfiguration = data.data.oidcServerConfiguration; const domains = getDomains(trustedDomain, 'oidc'); if (!domains.some(domain => domain === acceptAnyDomainToken)) { [ oidcServerConfiguration.tokenEndpoint, oidcServerConfiguration.revocationEndpoint, oidcServerConfiguration.userInfoEndpoint, oidcServerConfiguration.issuer, ].forEach(u => { checkDomain(domains, u); }); } currentDatabase.oidcServerConfiguration = oidcServerConfiguration; currentDatabase.oidcConfiguration = data.data.oidcConfiguration; // Cas DPoP if (currentDatabase.demonstratingProofOfPossessionConfiguration == null) { const demonstratingProofOfPossessionConfiguration = getDpopConfiguration(trustedDomain); if (demonstratingProofOfPossessionConfiguration != null) { if (currentDatabase.oidcConfiguration.demonstrating_proof_of_possession) { console.warn( 'In service worker, demonstrating_proof_of_possession must be configured from trustedDomains file', ); } currentDatabase.demonstratingProofOfPossessionConfiguration = demonstratingProofOfPossessionConfiguration; currentDatabase.demonstratingProofOfPossessionJwkJson = await generateJwkAsync(self)( demonstratingProofOfPossessionConfiguration.generateKeyAlgorithm, ); currentDatabase.demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent = getDpopOnlyWhenDpopHeaderPresent(trustedDomain) ?? false; } } if (!currentDatabase.tokens) { port.postMessage({ tokens: null, status: currentDatabase.status, configurationName, version, }); } else { const tokens = { ...currentDatabase.tokens }; if (currentDatabase.hideAccessToken) { tokens.access_token = `${TOKEN.ACCESS_TOKEN}_${configurationName}#tabId=${tabId}`; } if (tokens.refresh_token) { tokens.refresh_token = `${TOKEN.REFRESH_TOKEN}_${configurationName}#tabId=${tabId}`; } if (tokens?.idTokenPayload?.nonce && currentDatabase.nonce != null) { tokens.idTokenPayload.nonce = `${TOKEN.NONCE_TOKEN}_${configurationName}#tabId=${tabId}`; } port.postMessage({ tokens, status: currentDatabase.status, configurationName, version, }); } return; } case 'setDemonstratingProofOfPossessionNonce': { currentDatabase.demonstratingProofOfPossessionNonce = data.data.demonstratingProofOfPossessionNonce; port.postMessage({ configurationName }); return; } case 'getDemonstratingProofOfPossessionNonce': { const demonstratingProofOfPossessionNonce = currentDatabase.demonstratingProofOfPossessionNonce; port.postMessage({ configurationName, demonstratingProofOfPossessionNonce, }); return; } case 'setState': { currentDatabase.state = data.data.state; port.postMessage({ configurationName }); return; } case 'getState': { const state = currentDatabase.state; port.postMessage({ configurationName, state }); return; } case 'setCodeVerifier': { currentDatabase.codeVerifier = data.data.codeVerifier; port.postMessage({ configurationName }); return; } case 'getCodeVerifier': { const codeVerifier = currentDatabase.codeVerifier != null ? `${TOKEN.CODE_VERIFIER}_${configurationName}#tabId=${tabId}` : null; port.postMessage({ configurationName, codeVerifier, }); return; } case 'setSessionState': { currentDatabase.sessionState = data.data.sessionState; port.postMessage({ configurationName }); return; } case 'getSessionState': { const sessionState = currentDatabase.sessionState; port.postMessage({ configurationName, sessionState }); return; } case 'setNonce': { const nonce = data.data.nonce; if (nonce) { currentDatabase.nonce = nonce; } port.postMessage({ configurationName }); return; } case 'getNonce': { const keyNonce = `${TOKEN.NONCE_TOKEN}_${configurationName}#tabId=${tabId}`; const nonce = currentDatabase.nonce ? keyNonce : null; port.postMessage({ configurationName, nonce }); return; } default: return; } }; // Écouteurs _self.addEventListener('fetch', handleFetch); _self.addEventListener('message', handleMessage);