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