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