1 | import { CodedError } from '@unimodules/core';
|
2 | import compareUrls from 'compare-urls';
|
3 | import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
4 | import { AppState, Dimensions, AppStateStatus } from 'react-native';
|
5 |
|
6 | import {
|
7 | WebBrowserAuthSessionResult,
|
8 | WebBrowserOpenOptions,
|
9 | WebBrowserResult,
|
10 | WebBrowserResultType,
|
11 | WebBrowserWindowFeatures,
|
12 | } from './WebBrowser.types';
|
13 |
|
14 | const POPUP_WIDTH = 500;
|
15 | const POPUP_HEIGHT = 650;
|
16 |
|
17 | let popupWindow: Window | null = null;
|
18 |
|
19 | const listenerMap = new Map();
|
20 |
|
21 | const getHandle = () => 'ExpoWebBrowserRedirectHandle';
|
22 | const getOriginUrlHandle = (hash: string) => `ExpoWebBrowser_OriginUrl_${hash}`;
|
23 | const getRedirectUrlHandle = (hash: string) => `ExpoWebBrowser_RedirectUrl_${hash}`;
|
24 |
|
25 | function 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 |
|
48 | export 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 |
|
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 |
|
98 | window.localStorage.setItem(getOriginUrlHandle(handle), url);
|
99 |
|
100 |
|
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 |
|
109 | parent.postMessage({ url, expoSender: handle }, parent.location);
|
110 | return { type: 'success', message: `Attempting to complete auth` };
|
111 |
|
112 |
|
113 | },
|
114 |
|
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 |
|
127 | window.localStorage.setItem(getHandle(), state);
|
128 |
|
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 |
|
149 | const listener = (event: MessageEvent) => {
|
150 | if (!event.isTrusted) return;
|
151 |
|
152 | if (event.origin !== window.location.origin) {
|
153 | return;
|
154 | }
|
155 | const { data } = event;
|
156 |
|
157 | const handle = window.localStorage.getItem(getHandle());
|
158 |
|
159 | if (data.expoSender === handle) {
|
160 | dismissPopup();
|
161 | resolve({ type: 'success', url: data.url });
|
162 | }
|
163 | };
|
164 |
|
165 |
|
166 | window.addEventListener('message', listener, false);
|
167 |
|
168 |
|
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 |
|
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 |
|
195 | listenerMap.set(popupWindow, {
|
196 | listener,
|
197 | interval,
|
198 | appStateListener,
|
199 | });
|
200 | });
|
201 | },
|
202 | };
|
203 |
|
204 |
|
205 | function isCryptoAvailable(): boolean {
|
206 | if (!canUseDOM) return false;
|
207 | return !!(window?.crypto as any);
|
208 | }
|
209 |
|
210 | function isSubtleCryptoAvailable(): boolean {
|
211 | if (!isCryptoAvailable()) return false;
|
212 | return !!(window.crypto.subtle as any);
|
213 | }
|
214 |
|
215 | async 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 |
|
219 | return url.searchParams.get('state')!;
|
220 | }
|
221 |
|
222 | return await generateStateAsync();
|
223 | }
|
224 |
|
225 | function 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 |
|
232 | return url.searchParams.get('redirect_uri')!;
|
233 | }
|
234 |
|
235 | return location.origin + location.pathname;
|
236 | }
|
237 |
|
238 | const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
239 |
|
240 | async 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 |
|
256 | function 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 |
|
272 | function 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 |
|
282 |
|
283 |
|
284 | function normalizePopupFeaturesString(
|
285 | options?: WebBrowserWindowFeatures | string
|
286 | ): Record<string, any> {
|
287 | let windowFeatures: Record<string, any> = {};
|
288 |
|
289 | if (typeof options === 'string') {
|
290 |
|
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 |
|
305 | function 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 |
|
316 |
|
317 | return featureObjectToString({
|
318 | ...windowFeatures,
|
319 |
|
320 | toolbar: windowFeatures.toolbar ?? 'no',
|
321 | menubar: windowFeatures.menubar ?? 'no',
|
322 |
|
323 | location: windowFeatures.location ?? 'yes',
|
324 | resizable: windowFeatures.resizable ?? 'yes',
|
325 |
|
326 | status: windowFeatures.status ?? 'no',
|
327 | scrollbars: windowFeatures.scrollbars ?? 'yes',
|
328 | top,
|
329 | left,
|
330 | width,
|
331 | height,
|
332 | });
|
333 | }
|
334 |
|
335 | export 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 |