UNPKG

@auth0/auth0-spa-js

Version:

Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE

362 lines (297 loc) 10.2 kB
import { MissingRefreshTokenError } from '../errors'; import { FetchResponse } from '../global'; import { createQueryParams, fromEntries } from '../utils'; import { WorkerMessage, WorkerRefreshTokenMessage, WorkerRevokeTokenMessage } from './worker.types'; let refreshTokens: Record<string, string> = {}; let allowedBaseUrl: string | null = null; const cacheKey = (audience: string, scope: string) => `${audience}|${scope}`; const cacheKeyContainsAudience = (audience: string, cacheKey: string) => cacheKey.startsWith(`${audience}|`); const getRefreshToken = (audience: string, scope: string): string | undefined => refreshTokens[cacheKey(audience, scope)]; const setRefreshToken = ( refreshToken: string, audience: string, scope: string ) => (refreshTokens[cacheKey(audience, scope)] = refreshToken); const deleteRefreshToken = (audience: string, scope: string) => delete refreshTokens[cacheKey(audience, scope)]; const getRefreshTokensByAudience = (audience: string): string[] => { const seen = new Set<string>(); Object.entries(refreshTokens).forEach(([key, token]) => { if (cacheKeyContainsAudience(audience, key)) { seen.add(token); } }); return Array.from(seen); }; const deleteRefreshTokensByValue = (refreshToken: string): void => { Object.entries(refreshTokens).forEach(([key, token]) => { if (token === refreshToken) { delete refreshTokens[key]; } }); }; const wait = (time: number) => new Promise<void>(resolve => setTimeout(resolve, time)); const formDataToObject = (formData: string): Record<string, any> => { const queryParams = new URLSearchParams(formData); const parsedQuery: any = {}; queryParams.forEach((val, key) => { parsedQuery[key] = val; }); return parsedQuery; }; const updateRefreshTokens = (oldRefreshToken: string | undefined, newRefreshToken: string): void => { Object.entries(refreshTokens).forEach(([key, token]) => { if (token === oldRefreshToken) { refreshTokens[key] = newRefreshToken; } }); } const checkDownscoping = (scope: string, audience: string): boolean => { const findCoincidence = Object.keys(refreshTokens).find((key) => { if (key !== 'latest_refresh_token') { const isSameAudience = cacheKeyContainsAudience(audience, key); const scopesKey = key.split('|')[1].split(" "); const requestedScopes = scope.split(" "); const scopesAreIncluded = requestedScopes.every((key) => scopesKey.includes(key)); return isSameAudience && scopesAreIncluded; } }) return findCoincidence ? true : false; } const messageHandler = async ({ data: { timeout, auth, fetchUrl, fetchOptions, useFormData, useMrrt }, ports: [port] }: MessageEvent<WorkerRefreshTokenMessage>) => { let headers: FetchResponse['headers'] = {}; let json: { refresh_token?: string; }; let refreshToken: string | undefined; const { audience, scope } = auth || {}; try { const body = useFormData ? formDataToObject(fetchOptions.body as string) : JSON.parse(fetchOptions.body as string); if (!body.refresh_token && body.grant_type === 'refresh_token') { refreshToken = getRefreshToken(audience, scope); // When we don't have any refresh_token that matches the audience and scopes // stored, and useMrrt is configured to true, we will use the last refresh_token // returned by the server to do a refresh // We will avoid doing MRRT if we were to downscope while doing refresh in the same audience if (!refreshToken && useMrrt) { const latestRefreshToken = refreshTokens["latest_refresh_token"]; const isDownscoping = checkDownscoping(scope, audience); if (latestRefreshToken && !isDownscoping) { refreshToken = latestRefreshToken; } } if (!refreshToken) { throw new MissingRefreshTokenError(audience, scope); } fetchOptions.body = useFormData ? createQueryParams({ ...body, refresh_token: refreshToken }) : JSON.stringify({ ...body, refresh_token: refreshToken }); } let abortController: AbortController | undefined; if (typeof AbortController === 'function') { abortController = new AbortController(); fetchOptions.signal = abortController.signal; } let response: void | Response; try { response = await Promise.race([ wait(timeout), fetch(fetchUrl, { ...fetchOptions }) ]); } catch (error) { // fetch error, reject `sendMessage` using `error` key so that we retry. port.postMessage({ error: error.message }); return; } if (!response) { // If the request times out, abort it and let `switchFetch` raise the error. if (abortController) abortController.abort(); port.postMessage({ error: "Timeout when executing 'fetch'" }); return; } headers = fromEntries(response.headers); json = await response.json(); if (json.refresh_token) { // If useMrrt is configured to true we want to save the latest refresh_token // to be used when refreshing tokens with MRRT if (useMrrt) { refreshTokens["latest_refresh_token"] = json.refresh_token; // To avoid having some refresh_token that has already been used // we will update those inside the list with the new one obtained // by the server updateRefreshTokens(refreshToken, json.refresh_token); } setRefreshToken(json.refresh_token, audience, scope); delete json.refresh_token; } else { deleteRefreshToken(audience, scope); } port.postMessage({ ok: response.ok, json, headers }); } catch (error) { port.postMessage({ ok: false, json: { error: error.error, error_description: error.message }, headers }); } }; const revokeMessageHandler = async ({ data: { timeout, auth, fetchUrl, fetchOptions, useFormData }, ports: [port] }: MessageEvent<WorkerRevokeTokenMessage>) => { const { audience } = auth || {}; try { const tokensToRevoke = getRefreshTokensByAudience(audience); if (tokensToRevoke.length === 0) { port.postMessage({ ok: true }); return; } // Parse the base body once; rebuild per RT so each request is independent. const baseBody = useFormData ? formDataToObject(fetchOptions.body as string) : JSON.parse(fetchOptions.body as string); for (const refreshToken of tokensToRevoke) { const body = useFormData ? createQueryParams({ ...baseBody, token: refreshToken }) : JSON.stringify({ ...baseBody, token: refreshToken }); let abortController: AbortController | undefined; let signal: AbortSignal | undefined; if (typeof AbortController === 'function') { abortController = new AbortController(); signal = abortController.signal; } let timeoutId: ReturnType<typeof setTimeout>; let response: void | Response; try { response = await Promise.race([ new Promise<void>(resolve => { timeoutId = setTimeout(resolve, timeout); }), fetch(fetchUrl, { ...fetchOptions, body, signal }) ]).finally(() => clearTimeout(timeoutId)); } catch (error) { port.postMessage({ error: error.message }); return; } if (!response) { if (abortController) abortController.abort(); port.postMessage({ error: "Timeout when executing 'fetch'" }); return; } if (!response.ok) { let errorDescription: string | undefined; try { const { error_description } = JSON.parse(await response.text()); errorDescription = error_description; } catch { // body absent or not valid JSON } port.postMessage({ error: errorDescription || `HTTP error ${response.status}` }); return; } deleteRefreshTokensByValue(refreshToken); } port.postMessage({ ok: true }); } catch (error) { port.postMessage({ error: error.message || 'Unknown error during token revocation' }); } }; const isAuthorizedWorkerRequest = ( workerRequest: WorkerRefreshTokenMessage | WorkerRevokeTokenMessage, expectedPath: string ) => { if (!allowedBaseUrl) { return false; } try { const allowedBaseOrigin = new URL(allowedBaseUrl).origin; const requestedUrl = new URL(workerRequest.fetchUrl); return ( requestedUrl.origin === allowedBaseOrigin && requestedUrl.pathname === expectedPath ); } catch { return false; } }; const messageRouter = (event: MessageEvent<WorkerMessage>) => { const { data, ports } = event; const [port] = ports; if ('type' in data && data.type === 'init') { if (allowedBaseUrl === null) { try { new URL(data.allowedBaseUrl); allowedBaseUrl = data.allowedBaseUrl; } catch { return; } } return; } if ('type' in data && data.type === 'revoke') { if (!isAuthorizedWorkerRequest(data as WorkerRevokeTokenMessage, '/oauth/revoke')) { port?.postMessage({ ok: false, json: { error: 'invalid_fetch_url', error_description: 'Unauthorized fetch URL' }, headers: {} }); return; } revokeMessageHandler(event as MessageEvent<WorkerRevokeTokenMessage>); return; } if ( !('fetchUrl' in data) || !isAuthorizedWorkerRequest(data as WorkerRefreshTokenMessage, '/oauth/token') ) { port?.postMessage({ ok: false, json: { error: 'invalid_fetch_url', error_description: 'Unauthorized fetch URL' }, headers: {} }); return; } messageHandler(event as MessageEvent<WorkerRefreshTokenMessage>); }; // Don't run `addEventListener` in our tests (this is replaced in rollup) if (process.env.NODE_ENV === 'test') { module.exports = { messageHandler, revokeMessageHandler, messageRouter }; /* c8 ignore next 4 */ } else { // @ts-ignore addEventListener('message', messageRouter); }