287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
import { CodedError, Platform } from '@unimodules/core';
|
|
import compareUrls from 'compare-urls';
|
|
import { AppState, Dimensions } from 'react-native';
|
|
import { WebBrowserResultType, } from './WebBrowser.types';
|
|
const POPUP_WIDTH = 500;
|
|
const POPUP_HEIGHT = 650;
|
|
let popupWindow = null;
|
|
const listenerMap = new Map();
|
|
const getHandle = () => 'ExpoWebBrowserRedirectHandle';
|
|
const getOriginUrlHandle = (hash) => `ExpoWebBrowser_OriginUrl_${hash}`;
|
|
const getRedirectUrlHandle = (hash) => `ExpoWebBrowser_RedirectUrl_${hash}`;
|
|
function dismissPopup() {
|
|
if (!popupWindow) {
|
|
return;
|
|
}
|
|
popupWindow.close();
|
|
if (listenerMap.has(popupWindow)) {
|
|
const { listener, appStateListener, interval } = listenerMap.get(popupWindow);
|
|
clearInterval(interval);
|
|
window.removeEventListener('message', listener);
|
|
AppState.removeEventListener('change', appStateListener);
|
|
listenerMap.delete(popupWindow);
|
|
const handle = window.localStorage.getItem(getHandle());
|
|
if (handle) {
|
|
window.localStorage.removeItem(getHandle());
|
|
window.localStorage.removeItem(getOriginUrlHandle(handle));
|
|
window.localStorage.removeItem(getRedirectUrlHandle(handle));
|
|
}
|
|
popupWindow = null;
|
|
}
|
|
}
|
|
export default {
|
|
get name() {
|
|
return 'ExpoWebBrowser';
|
|
},
|
|
async openBrowserAsync(url, browserParams = {}) {
|
|
if (!Platform.isDOMAvailable)
|
|
return { type: WebBrowserResultType.CANCEL };
|
|
const { windowName = '_blank', windowFeatures } = browserParams;
|
|
const features = getPopupFeaturesString(windowFeatures);
|
|
window.open(url, windowName, features);
|
|
return { type: WebBrowserResultType.OPENED };
|
|
},
|
|
dismissAuthSession() {
|
|
if (!Platform.isDOMAvailable)
|
|
return;
|
|
dismissPopup();
|
|
},
|
|
maybeCompleteAuthSession({ skipRedirectCheck, }) {
|
|
if (!Platform.isDOMAvailable) {
|
|
return {
|
|
type: 'failed',
|
|
message: 'Cannot use expo-web-browser in a non-browser environment',
|
|
};
|
|
}
|
|
const handle = window.localStorage.getItem(getHandle());
|
|
if (!handle) {
|
|
return { type: 'failed', message: 'No auth session is currently in progress' };
|
|
}
|
|
const url = window.location.href;
|
|
if (skipRedirectCheck !== true) {
|
|
const redirectUrl = window.localStorage.getItem(getRedirectUrlHandle(handle));
|
|
// Compare the original redirect url against the current url with it's query params removed.
|
|
const currentUrl = window.location.origin + window.location.pathname;
|
|
if (!compareUrls(redirectUrl, currentUrl)) {
|
|
return {
|
|
type: 'failed',
|
|
message: `Current URL "${currentUrl}" and original redirect URL "${redirectUrl}" do not match.`,
|
|
};
|
|
}
|
|
}
|
|
// Save the link for app state listener
|
|
window.localStorage.setItem(getOriginUrlHandle(handle), url);
|
|
// Get the window that created the current popup
|
|
const parent = window.opener ?? window.parent;
|
|
if (!parent) {
|
|
throw new CodedError('ERR_WEB_BROWSER_REDIRECT', `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.`);
|
|
}
|
|
// Send the URL back to the opening window.
|
|
parent.postMessage({ url, expoSender: handle }, parent.location);
|
|
return { type: 'success', message: `Attempting to complete auth` };
|
|
// Maybe set timer to throw an error if the window is still open after attempting to complete.
|
|
},
|
|
// This method should be invoked from user input.
|
|
async openAuthSessionAsync(url, redirectUrl, openOptions) {
|
|
if (!Platform.isDOMAvailable)
|
|
return { type: WebBrowserResultType.CANCEL };
|
|
redirectUrl = redirectUrl ?? getRedirectUrlFromUrlOrGenerate(url);
|
|
const state = await getStateFromUrlOrGenerateAsync(url);
|
|
// Save handle for session
|
|
window.localStorage.setItem(getHandle(), state);
|
|
// Save redirect Url for further verification
|
|
window.localStorage.setItem(getRedirectUrlHandle(state), redirectUrl);
|
|
if (popupWindow == null || popupWindow?.closed) {
|
|
const features = getPopupFeaturesString(openOptions?.windowFeatures);
|
|
popupWindow = window.open(url, openOptions?.windowName, features);
|
|
if (popupWindow) {
|
|
try {
|
|
popupWindow.focus();
|
|
}
|
|
catch (e) { }
|
|
}
|
|
else {
|
|
throw new CodedError('ERR_WEB_BROWSER_BLOCKED', '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.');
|
|
}
|
|
}
|
|
return new Promise(async (resolve) => {
|
|
// Create a listener for messages sent from the popup
|
|
const listener = (event) => {
|
|
if (!event.isTrusted)
|
|
return;
|
|
// Ensure we trust the sender.
|
|
if (event.origin !== window.location.origin) {
|
|
return;
|
|
}
|
|
const { data } = event;
|
|
// Use a crypto hash to invalid message.
|
|
const handle = window.localStorage.getItem(getHandle());
|
|
// Ensure the sender is also from expo-web-browser
|
|
if (data.expoSender === handle) {
|
|
dismissPopup();
|
|
resolve({ type: 'success', url: data.url });
|
|
}
|
|
};
|
|
// Add a listener for receiving messages from the popup.
|
|
window.addEventListener('message', listener, false);
|
|
// Create an app state listener as a fallback to the popup listener
|
|
const appStateListener = (state) => {
|
|
if (state !== 'active') {
|
|
return;
|
|
}
|
|
const handle = window.localStorage.getItem(getHandle());
|
|
if (handle) {
|
|
const url = window.localStorage.getItem(getOriginUrlHandle(handle));
|
|
if (url) {
|
|
dismissPopup();
|
|
resolve({ type: 'success', url });
|
|
}
|
|
}
|
|
};
|
|
AppState.addEventListener('change', appStateListener);
|
|
// Check if the window has been closed every second.
|
|
const interval = setInterval(() => {
|
|
if (popupWindow?.closed) {
|
|
if (resolve)
|
|
resolve({ type: WebBrowserResultType.DISMISS });
|
|
clearInterval(interval);
|
|
dismissPopup();
|
|
}
|
|
}, 1000);
|
|
// Store the listener and interval for clean up.
|
|
listenerMap.set(popupWindow, {
|
|
listener,
|
|
interval,
|
|
appStateListener,
|
|
});
|
|
});
|
|
},
|
|
};
|
|
// Crypto
|
|
function isCryptoAvailable() {
|
|
if (!Platform.isDOMAvailable)
|
|
return false;
|
|
return !!window?.crypto;
|
|
}
|
|
function isSubtleCryptoAvailable() {
|
|
if (!isCryptoAvailable())
|
|
return false;
|
|
return !!window.crypto.subtle;
|
|
}
|
|
async function getStateFromUrlOrGenerateAsync(inputUrl) {
|
|
const url = new URL(inputUrl);
|
|
if (url.searchParams.has('state') && typeof url.searchParams.get('state') === 'string') {
|
|
// Ensure we reuse the auth state if it's passed in.
|
|
return url.searchParams.get('state');
|
|
}
|
|
// Generate a crypto state for verifying the return popup.
|
|
return await generateStateAsync();
|
|
}
|
|
function getRedirectUrlFromUrlOrGenerate(inputUrl) {
|
|
const url = new URL(inputUrl);
|
|
if (url.searchParams.has('redirect_uri') &&
|
|
typeof url.searchParams.get('redirect_uri') === 'string') {
|
|
// Ensure we reuse the redirect_uri if it's passed in the input url.
|
|
return url.searchParams.get('redirect_uri');
|
|
}
|
|
// Emulate how native uses Constants.linkingUrl
|
|
return location.origin + location.pathname;
|
|
}
|
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
async function generateStateAsync() {
|
|
if (!isSubtleCryptoAvailable()) {
|
|
throw new CodedError('ERR_WEB_BROWSER_CRYPTO', `The current environment doesn't support crypto. Ensure you are running from a secure origin (https).`);
|
|
}
|
|
const encoder = new TextEncoder();
|
|
const data = generateRandom(10);
|
|
const buffer = encoder.encode(data);
|
|
const hashedData = await crypto.subtle.digest('SHA-256', buffer);
|
|
const state = btoa(String.fromCharCode(...new Uint8Array(hashedData)));
|
|
return state;
|
|
}
|
|
function generateRandom(size) {
|
|
let arr = new Uint8Array(size);
|
|
if (arr.byteLength !== arr.length) {
|
|
arr = new Uint8Array(arr.buffer);
|
|
}
|
|
const array = new Uint8Array(arr.length);
|
|
if (isCryptoAvailable()) {
|
|
window.crypto.getRandomValues(array);
|
|
}
|
|
else {
|
|
for (let i = 0; i < size; i += 1) {
|
|
array[i] = (Math.random() * CHARSET.length) | 0;
|
|
}
|
|
}
|
|
return bufferToString(array);
|
|
}
|
|
function bufferToString(buffer) {
|
|
const state = [];
|
|
for (let i = 0; i < buffer.byteLength; i += 1) {
|
|
const index = buffer[i] % CHARSET.length;
|
|
state.push(CHARSET[index]);
|
|
}
|
|
return state.join('');
|
|
}
|
|
// Window Features
|
|
// Ensure feature string is an object
|
|
function normalizePopupFeaturesString(options) {
|
|
let windowFeatures = {};
|
|
// This should be avoided because it adds extra time to the popup command.
|
|
if (typeof options === 'string') {
|
|
// Convert string of `key=value,foo=bar` into an object
|
|
const windowFeaturePairs = options.split(',');
|
|
for (const pair of windowFeaturePairs) {
|
|
const [key, value] = pair.trim().split('=');
|
|
if (key && value) {
|
|
windowFeaturePairs[key] = value;
|
|
}
|
|
}
|
|
}
|
|
else if (options) {
|
|
windowFeatures = options;
|
|
}
|
|
return windowFeatures;
|
|
}
|
|
// Apply default values to the input feature set
|
|
function getPopupFeaturesString(options) {
|
|
const windowFeatures = normalizePopupFeaturesString(options);
|
|
const width = windowFeatures.width ?? POPUP_WIDTH;
|
|
const height = windowFeatures.height ?? POPUP_HEIGHT;
|
|
const dimensions = Dimensions.get('screen');
|
|
const top = windowFeatures.top ?? Math.max(0, (dimensions.height - height) * 0.5);
|
|
const left = windowFeatures.left ?? Math.max(0, (dimensions.width - width) * 0.5);
|
|
// Create a reasonable popup
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Window/open#Window_features
|
|
return featureObjectToString({
|
|
...windowFeatures,
|
|
// Toolbar buttons (Back, Forward, Reload, Stop buttons).
|
|
toolbar: windowFeatures.toolbar ?? 'no',
|
|
menubar: windowFeatures.menubar ?? 'no',
|
|
// Shows the location bar or the address bar.
|
|
location: windowFeatures.location ?? 'yes',
|
|
resizable: windowFeatures.resizable ?? 'yes',
|
|
// If this feature is on, then the new secondary window has a status bar.
|
|
status: windowFeatures.status ?? 'no',
|
|
scrollbars: windowFeatures.scrollbars ?? 'yes',
|
|
top,
|
|
left,
|
|
width,
|
|
height,
|
|
});
|
|
}
|
|
export function featureObjectToString(features) {
|
|
return Object.keys(features).reduce((prev, current) => {
|
|
let value = features[current];
|
|
if (typeof value === 'boolean') {
|
|
value = value ? 'yes' : 'no';
|
|
}
|
|
if (current && value) {
|
|
if (prev)
|
|
prev += ',';
|
|
return `${prev}${current}=${value}`;
|
|
}
|
|
return prev;
|
|
}, '');
|
|
}
|
|
//# sourceMappingURL=ExpoWebBrowser.web.js.map
|