@axa-fr/oidc-client-service-worker
Version:
OpenID Connect & OAuth authentication service worker
621 lines (558 loc) • 22.9 kB
text/typescript
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);