import type { AuthcClientOptions, AuthenticationResult, PopupConfigOptions } from './global' import { CLEANUP_IFRAME_TIMEOUT_IN_SECONDS, DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS, } from './constants' import { GenericError, PopupCancelledError, PopupTimeoutError, TimeoutError, } from './errors' export const parseQueryResult = (queryString: string): AuthenticationResult => { if (queryString.includes('#')) queryString = queryString.substr(0, queryString.indexOf('#')) const queryParams = queryString.split('&') const parsedQuery: Record = {} queryParams.forEach((qp) => { const [key, val] = qp.split('=') parsedQuery[key] = decodeURIComponent(val) }) if (parsedQuery.expires_in) parsedQuery.expires_in = parseInt(parsedQuery.expires_in) return parsedQuery as AuthenticationResult } export const runIframe = ( authorizeUrl: string, eventOrigin: string, timeoutInSeconds: number = DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS, options: AuthcClientOptions, ) => { return new Promise((res, rej) => { const iframe = window.document.createElement('iframe') iframe.setAttribute('width', '0') iframe.setAttribute('height', '0') iframe.style.display = 'none' let iframeEventHandler: (e: MessageEvent) => void const removeIframe = () => { if (window.document.body.contains(iframe)) { window.document.body.removeChild(iframe) window.removeEventListener('message', iframeEventHandler, false) } } const timeoutSetTimeoutId = setTimeout(() => { rej(new TimeoutError()) removeIframe() }, timeoutInSeconds * 1000) iframeEventHandler = function (e: MessageEvent) { if (e.origin !== eventOrigin && !options.redirect_uri?.includes(e.origin)) { if (e.data?.type === 'authorization_response') console.warn('Received message is not from the expected origin', e.origin, eventOrigin, options.redirect_uri) return } if (!e.data || e.data.type !== 'authorization_response') return const eventSource = e.source if (eventSource) (eventSource as any).close() e.data.response.error ? rej(GenericError.fromPayload(e.data.response)) : res(e.data.response) clearTimeout(timeoutSetTimeoutId) window.removeEventListener('message', iframeEventHandler, false) // Delay the removal of the iframe to prevent hanging loading status // in Chrome: https://github.com/authc/authc-spa-js/issues/240 setTimeout(removeIframe, CLEANUP_IFRAME_TIMEOUT_IN_SECONDS * 1000) } window.addEventListener('message', iframeEventHandler, false) window.document.body.appendChild(iframe) iframe.setAttribute('src', authorizeUrl) }) } export const openPopup = (url: string) => { const width = 400 const height = 600 const left = window.screenX + (window.innerWidth - width) / 2 const top = window.screenY + (window.innerHeight - height) / 2 return window.open( url, 'authc:authorize:popup', `left=${left},top=${top},width=${width},height=${height},resizable,scrollbars=yes,status=1`, ) } export const runPopup = (config: PopupConfigOptions) => { return new Promise((resolve, reject) => { let popupEventListener: EventListenerOrEventListenerObject let timeoutId: ReturnType = null // Check each second if the popup is closed triggering a PopupCancelledError const popupTimer = setInterval(() => { if (config.popup && config.popup.closed) { clearInterval(popupTimer) clearTimeout(timeoutId) window.removeEventListener('message', popupEventListener, false) reject(new PopupCancelledError(config.popup)) } }, 1000) timeoutId = setTimeout(() => { clearInterval(popupTimer) reject(new PopupTimeoutError(config.popup)) window.removeEventListener('message', popupEventListener, false) }, (config.timeoutInSeconds || DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS) * 1000) popupEventListener = function (e: MessageEvent) { if (!e.data || e.data.type !== 'authorization_response') return clearTimeout(timeoutId) clearInterval(popupTimer) window.removeEventListener('message', popupEventListener, false) config.popup.close() if (e.data.response.error) return reject(GenericError.fromPayload(e.data.response)) resolve(e.data.response) } window.addEventListener('message', popupEventListener) }) } export const getCrypto = () => { // ie 11.x uses msCrypto return (window.crypto || (window as any).msCrypto) as Crypto } export const getCryptoSubtle = () => { const crypto = getCrypto() // safari 10.x uses webkitSubtle return crypto.subtle || (crypto as any).webkitSubtle } export const createRandomString = () => { const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_~' let random = '' const randomValues = Array.from( getCrypto().getRandomValues(new Uint8Array(43)), ) randomValues.forEach(v => (random += charset[v % charset.length])) return random } export const encode = (value: string) => encodeURIComponent(value) export const decode = (value: string) => decodeURIComponent(value) export const createQueryParams = (params: any) => { return Object.keys(params) .filter(k => typeof params[k] !== 'undefined') .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&') } export const sha256 = async (s: string) => { const digestOp: any = getCryptoSubtle().digest( { name: 'SHA-256' }, new TextEncoder().encode(s), ) // msCrypto (IE11) uses the old spec, which is not Promise based // https://msdn.microsoft.com/en-us/expression/dn904640(v=vs.71) // Instead of returning a promise, it returns a CryptoOperation // with a result property in it. // As a result, the various events need to be handled in the event that we're // working in IE11 (hence the msCrypto check). These events just call resolve // or reject depending on their intention. if ((window as any).msCrypto) { return new Promise((res, rej) => { digestOp.oncomplete = (e: any) => { res(e.target.result) } digestOp.onerror = (e: ErrorEvent) => { rej(e.error) } digestOp.onabort = () => { rej('The digest operation was aborted') } }) } return await digestOp } const urlEncodeB64 = (input: string) => { const b64Chars: { [index: string]: string } = { '+': '-', '/': '_', '=': '' } return input.replace(/[+/=]/g, (m: string) => b64Chars[m]) } // https://stackoverflow.com/questions/30106476/ const decodeB64 = (input: string) => decodeURIComponent( atob(input) .split('') .map((c) => { return `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}` }) .join(''), ) export const urlDecodeB64 = (input: string) => decodeB64(input.replace(/_/g, '/').replace(/-/g, '+')) export const bufferToBase64UrlEncoded = (input: number[] | Uint8Array) => { const ie11SafeInput = new Uint8Array(input) return urlEncodeB64( window.btoa(String.fromCharCode(...Array.from(ie11SafeInput))), ) } export const validateCrypto = () => { if (!getCrypto()) { throw new Error( 'For security reasons, `window.crypto` is required to run `authc-spa-js`.', ) } if (typeof getCryptoSubtle() === 'undefined') { throw new TypeError(` authc-spa-js must run on a secure origin. See https://github.com/authc/authc-spa-js/blob/master/FAQ.md#why-do-i-get-authc-spa-js-must-run-on-a-secure-origin for more information. `) } } export const hasContainLoginCodeAndState = () => { return (location.hash.includes('code=') && location.hash.includes('state=')) || (location.search.includes('code=') && location.search.includes('state=')) } export const createLogoutIframe = (url: string) => { const iframe = document.createElement('iframe') iframe.style.position = 'fixed' iframe.style.zIndex = '-1' iframe.style.left = '-10000px' iframe.style.top = '-10000px' iframe.style.pointerEvents = 'none' iframe.src = url return new Promise((resolve, reject) => { window.document.body.appendChild(iframe) iframe.onload = () => { resolve() iframe.remove() } iframe.onerror = () => { reject() } }) } export async function retryOnFailure(promiseFactory: () => Promise, maxRetries = 3): Promise { let retries = 0 while (retries < maxRetries) { try { const result = await promiseFactory() return result } catch (error) { retries++ } } throw new Error(`Promise failed after ${maxRetries} retries.`) }