1import compareUrls from 'compare-urls'; 2import { CodedError, Platform } from 'expo-modules-core'; 3import { AppState, Dimensions } from 'react-native'; 4import { WebBrowserResultType, } from './WebBrowser.types'; 5const POPUP_WIDTH = 500; 6const POPUP_HEIGHT = 650; 7let popupWindow = null; 8const listenerMap = new Map(); 9const getHandle = () => 'ExpoWebBrowserRedirectHandle'; 10const getOriginUrlHandle = (hash) => `ExpoWebBrowser_OriginUrl_${hash}`; 11const getRedirectUrlHandle = (hash) => `ExpoWebBrowser_RedirectUrl_${hash}`; 12function dismissPopup() { 13 if (!popupWindow) { 14 return; 15 } 16 popupWindow.close(); 17 if (listenerMap.has(popupWindow)) { 18 const { listener, appStateSubscription, interval } = listenerMap.get(popupWindow); 19 clearInterval(interval); 20 window.removeEventListener('message', listener); 21 appStateSubscription.remove(); 22 listenerMap.delete(popupWindow); 23 const handle = window.localStorage.getItem(getHandle()); 24 if (handle) { 25 window.localStorage.removeItem(getHandle()); 26 window.localStorage.removeItem(getOriginUrlHandle(handle)); 27 window.localStorage.removeItem(getRedirectUrlHandle(handle)); 28 } 29 popupWindow = null; 30 } 31} 32export default { 33 get name() { 34 return 'ExpoWebBrowser'; 35 }, 36 async openBrowserAsync(url, browserParams = {}) { 37 if (!Platform.isDOMAvailable) 38 return { type: WebBrowserResultType.CANCEL }; 39 const { windowName = '_blank', windowFeatures } = browserParams; 40 const features = getPopupFeaturesString(windowFeatures); 41 window.open(url, windowName, features); 42 return { type: WebBrowserResultType.OPENED }; 43 }, 44 dismissAuthSession() { 45 if (!Platform.isDOMAvailable) 46 return; 47 dismissPopup(); 48 }, 49 maybeCompleteAuthSession({ skipRedirectCheck }) { 50 if (!Platform.isDOMAvailable) { 51 return { 52 type: 'failed', 53 message: 'Cannot use expo-web-browser in a non-browser environment', 54 }; 55 } 56 const handle = window.localStorage.getItem(getHandle()); 57 if (!handle) { 58 return { type: 'failed', message: 'No auth session is currently in progress' }; 59 } 60 const url = window.location.href; 61 if (skipRedirectCheck !== true) { 62 const redirectUrl = window.localStorage.getItem(getRedirectUrlHandle(handle)); 63 // Compare the original redirect url against the current url with it's query params removed. 64 const currentUrl = window.location.origin + window.location.pathname; 65 if (!compareUrls(redirectUrl, currentUrl)) { 66 return { 67 type: 'failed', 68 message: `Current URL "${currentUrl}" and original redirect URL "${redirectUrl}" do not match.`, 69 }; 70 } 71 } 72 // Save the link for app state listener 73 window.localStorage.setItem(getOriginUrlHandle(handle), url); 74 // Get the window that created the current popup 75 const parent = window.opener ?? window.parent; 76 if (!parent) { 77 throw new CodedError('ERR_WEB_BROWSER_REDIRECT', `The window cannot complete the redirect request because the invoking window doesn't have a reference to it's parent. This can happen if the parent window was reloaded.`); 78 } 79 // Send the URL back to the opening window. 80 parent.postMessage({ url, expoSender: handle }, parent.location.toString()); 81 return { type: 'success', message: `Attempting to complete auth` }; 82 // Maybe set timer to throw an error if the window is still open after attempting to complete. 83 }, 84 // This method should be invoked from user input. 85 async openAuthSessionAsync(url, redirectUrl, openOptions) { 86 if (!Platform.isDOMAvailable) 87 return { type: WebBrowserResultType.CANCEL }; 88 redirectUrl = redirectUrl ?? getRedirectUrlFromUrlOrGenerate(url); 89 if (popupWindow == null || popupWindow?.closed) { 90 const features = getPopupFeaturesString(openOptions?.windowFeatures); 91 popupWindow = window.open(url, openOptions?.windowName, features); 92 if (popupWindow) { 93 try { 94 popupWindow.focus(); 95 } 96 catch { } 97 } 98 else { 99 throw new CodedError('ERR_WEB_BROWSER_BLOCKED', 'Popup window was blocked by the browser or failed to open. This can happen in mobile browsers when the window.open() method was invoked too long after a user input was fired.'); 100 } 101 } 102 const state = await getStateFromUrlOrGenerateAsync(url); 103 // Save handle for session 104 window.localStorage.setItem(getHandle(), state); 105 // Save redirect Url for further verification 106 window.localStorage.setItem(getRedirectUrlHandle(state), redirectUrl); 107 return new Promise(async (resolve) => { 108 // Create a listener for messages sent from the popup 109 const listener = (event) => { 110 if (!event.isTrusted) 111 return; 112 // Ensure we trust the sender. 113 if (event.origin !== window.location.origin) { 114 return; 115 } 116 const { data } = event; 117 // Use a crypto hash to invalid message. 118 const handle = window.localStorage.getItem(getHandle()); 119 // Ensure the sender is also from expo-web-browser 120 if (data.expoSender === handle) { 121 dismissPopup(); 122 resolve({ type: 'success', url: data.url }); 123 } 124 }; 125 // Add a listener for receiving messages from the popup. 126 window.addEventListener('message', listener, false); 127 // Create an app state listener as a fallback to the popup listener 128 const appStateListener = (state) => { 129 if (state !== 'active') { 130 return; 131 } 132 const handle = window.localStorage.getItem(getHandle()); 133 if (handle) { 134 const url = window.localStorage.getItem(getOriginUrlHandle(handle)); 135 if (url) { 136 dismissPopup(); 137 resolve({ type: 'success', url }); 138 } 139 } 140 }; 141 const appStateSubscription = AppState.addEventListener('change', appStateListener); 142 // Check if the window has been closed every second. 143 const interval = setInterval(() => { 144 if (popupWindow?.closed) { 145 if (resolve) 146 resolve({ type: WebBrowserResultType.DISMISS }); 147 clearInterval(interval); 148 dismissPopup(); 149 } 150 }, 1000); 151 // Store the listener and interval for clean up. 152 listenerMap.set(popupWindow, { 153 listener, 154 interval, 155 appStateSubscription, 156 }); 157 }); 158 }, 159}; 160// Crypto 161function isCryptoAvailable() { 162 if (!Platform.isDOMAvailable) 163 return false; 164 return !!window?.crypto; 165} 166function isSubtleCryptoAvailable() { 167 if (!isCryptoAvailable()) 168 return false; 169 return !!window.crypto.subtle; 170} 171async function getStateFromUrlOrGenerateAsync(inputUrl) { 172 const url = new URL(inputUrl); 173 if (url.searchParams.has('state') && typeof url.searchParams.get('state') === 'string') { 174 // Ensure we reuse the auth state if it's passed in. 175 return url.searchParams.get('state'); 176 } 177 // Generate a crypto state for verifying the return popup. 178 return await generateStateAsync(); 179} 180function getRedirectUrlFromUrlOrGenerate(inputUrl) { 181 const url = new URL(inputUrl); 182 if (url.searchParams.has('redirect_uri') && 183 typeof url.searchParams.get('redirect_uri') === 'string') { 184 // Ensure we reuse the redirect_uri if it's passed in the input url. 185 return url.searchParams.get('redirect_uri'); 186 } 187 // Emulate how native uses Constants.linkingUrl 188 return location.origin + location.pathname; 189} 190const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 191async function generateStateAsync() { 192 if (!isSubtleCryptoAvailable()) { 193 throw new CodedError('ERR_WEB_BROWSER_CRYPTO', `The current environment doesn't support crypto. Ensure you are running from a secure origin (https).`); 194 } 195 const encoder = new TextEncoder(); 196 const data = generateRandom(10); 197 const buffer = encoder.encode(data); 198 const hashedData = await crypto.subtle.digest('SHA-256', buffer); 199 const state = btoa(String.fromCharCode(...new Uint8Array(hashedData))); 200 return state; 201} 202function generateRandom(size) { 203 let arr = new Uint8Array(size); 204 if (arr.byteLength !== arr.length) { 205 arr = new Uint8Array(arr.buffer); 206 } 207 const array = new Uint8Array(arr.length); 208 if (isCryptoAvailable()) { 209 window.crypto.getRandomValues(array); 210 } 211 else { 212 for (let i = 0; i < size; i += 1) { 213 array[i] = (Math.random() * CHARSET.length) | 0; 214 } 215 } 216 return bufferToString(array); 217} 218function bufferToString(buffer) { 219 const state = []; 220 for (let i = 0; i < buffer.byteLength; i += 1) { 221 const index = buffer[i] % CHARSET.length; 222 state.push(CHARSET[index]); 223 } 224 return state.join(''); 225} 226// Window Features 227// Ensure feature string is an object 228function normalizePopupFeaturesString(options) { 229 let windowFeatures = {}; 230 // This should be avoided because it adds extra time to the popup command. 231 if (typeof options === 'string') { 232 // Convert string of `key=value,foo=bar` into an object 233 const windowFeaturePairs = options.split(','); 234 for (const pair of windowFeaturePairs) { 235 const [key, value] = pair.trim().split('='); 236 if (key && value) { 237 windowFeatures[key] = value; 238 } 239 } 240 } 241 else if (options) { 242 windowFeatures = options; 243 } 244 return windowFeatures; 245} 246// Apply default values to the input feature set 247function getPopupFeaturesString(options) { 248 const windowFeatures = normalizePopupFeaturesString(options); 249 const width = windowFeatures.width ?? POPUP_WIDTH; 250 const height = windowFeatures.height ?? POPUP_HEIGHT; 251 const dimensions = Dimensions.get('screen'); 252 const top = windowFeatures.top ?? Math.max(0, (dimensions.height - height) * 0.5); 253 const left = windowFeatures.left ?? Math.max(0, (dimensions.width - width) * 0.5); 254 // Create a reasonable popup 255 // https://developer.mozilla.org/en-US/docs/Web/API/Window/open#Window_features 256 return featureObjectToString({ 257 ...windowFeatures, 258 // Toolbar buttons (Back, Forward, Reload, Stop buttons). 259 toolbar: windowFeatures.toolbar ?? 'no', 260 menubar: windowFeatures.menubar ?? 'no', 261 // Shows the location bar or the address bar. 262 location: windowFeatures.location ?? 'yes', 263 resizable: windowFeatures.resizable ?? 'yes', 264 // If this feature is on, then the new secondary window has a status bar. 265 status: windowFeatures.status ?? 'no', 266 scrollbars: windowFeatures.scrollbars ?? 'yes', 267 top, 268 left, 269 width, 270 height, 271 }); 272} 273export function featureObjectToString(features) { 274 return Object.keys(features).reduce((prev, current) => { 275 let value = features[current]; 276 if (typeof value === 'boolean') { 277 value = value ? 'yes' : 'no'; 278 } 279 if (current && value) { 280 if (prev) 281 prev += ','; 282 return `${prev}${current}=${value}`; 283 } 284 return prev; 285 }, ''); 286} 287//# sourceMappingURL=ExpoWebBrowser.web.js.map