UNPKG

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