UNPKG

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