import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import type {Disposable} from 'grainjs'; import { GristLoadConfig } from "app/common/gristUrls"; /** * Functions to perform server side authentication with Google. * * The authentication flow is performed by server side (app/server/lib/GoogleAuth.ts). Here we will * open up a popup with a stub html file (served by the server), that will redirect user to Google * Auth Service. In return, we will get authorization_code (which will be delivered by a postMessage * from the iframe), that when converted to authorization_token, can be used to access Google Drive * API. Accessing Google Drive files is done by the server, here we only ask for the permissions. * * Exposed methods are: * - getGoogleCodeForSending: asks google for a permission to create files on the drive (and read * them) * - getGoogleCodeForReading: asks google for a permission to read all files * - canReadPrivateFiles: Grist by default won't ask for permission to read all files, but can be * configured this way by an environmental variable. */ const G = getBrowserGlobals('window'); export const ACCESS_DENIED = "access_denied"; export const AUTH_INTERRUPTED = "auth_interrupted"; // https://developers.google.com/identity/protocols/oauth2/scopes#drive // "View and manage Google Drive files and folders that you have opened or created with this app" const APP_SCOPE = "https://www.googleapis.com/auth/drive.file"; // "See and download all your Google Drive files" const READ_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; export function getGoogleCodeForSending(owner: Disposable) { return getGoogleAuthCode(owner, APP_SCOPE); } export function getGoogleCodeForReading(owner: Disposable) { const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; // Default scope allows as to manage files we created. return getGoogleAuthCode(owner, gristConfig.googleDriveScope || APP_SCOPE); } /** * Checks if default scope for Google Drive integration will allow to access all personal files */ export function canReadPrivateFiles() { const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; return gristConfig.googleDriveScope === READ_SCOPE; } /** * Opens up a popup with server side Google Authentication. Returns a code that can be used * by server side to retrieve access_token required in Google Api. */ function getGoogleAuthCode(owner: Disposable, scope: string) { // Compute google auth server endpoint (grist endpoint for server side google authentication). // This endpoint renders a page that redirects user to Google Consent screen and after Google // sends a response, it will post this response back to us. // Message will be an object { code, error }. const authLink = getGoogleAuthEndpoint(scope); const authWindow = openPopup(authLink); return new Promise((resolve, reject) => { attachListener(owner, authWindow, async (event: MessageEvent|null) => { // If the no message, or window was closed (user closed it intentionally). if (!event || authWindow.closed) { reject(new Error(AUTH_INTERRUPTED)); return; } // For the first message (we expect only a single message) close the window. authWindow.close(); if (owner.isDisposed()) { reject(new Error(AUTH_INTERRUPTED)); return; } // Check response from the popup const response = (event.data || {}) as {code?: string, error?: string}; // - when user declined, report back, caller should stop current flow, if (response.error === "access_denied") { reject(new Error(ACCESS_DENIED)); return; } // - when there is no authorization, or error is different from what we expected - report to user. if (!response.code) { reject(new Error(response.error || "Missing authorization code")); return; } resolve(response.code); }); }); } // Helper function that attaches a handler to message event from a popup window. function attachListener(owner: Disposable, popup: Window, listener: (e: MessageEvent|null) => void) { const wrapped = (e: MessageEvent) => { // Listen to events only from our window. if (e.source !== popup) { return; } // In case when Grist was reloaded or user navigated away - do nothing. if (owner.isDisposed()) { return; } listener(e); // Clear the listener, to avoid orphaned calls from closed event. listener = () => {}; }; // Unfortunately there is no ease way to detect if user has closed the popup. const closeHandler = onClose(popup, () => { listener(null); // Clear the listener, to avoid orphaned messages from window. listener = () => {}; }); owner.onDispose(closeHandler); G.window.addEventListener('message', wrapped); owner.onDispose(() => { G.window.removeEventListener('message', wrapped); }); } // Periodically checks if the window is closed. // Returns a function that can be used to cancel the event. function onClose(window: Window, clb: () => void) { const interval = setInterval(() => { if (window.closed) { clearInterval(interval); clb(); } }, 1000); return () => clearInterval(interval); } function openPopup(url: string): Window { // Center window on desktop // https://stackoverflow.com/questions/16363474/window-open-on-a-multi-monitor-dual-monitor-system-where-does-window-pop-up const width = 600; const height = 650; const left = window.screenX + (screen.width - width) / 2; const top = (screen.height - height) / 4; let windowFeatures = `top=${top},left=${left},menubar=no,location=no,` + `resizable=yes,scrollbars=yes,status=yes,height=${height},width=${width}`; // If window will be too large (for example on mobile) - open as a new tab if (screen.width <= width || screen.height <= height) { windowFeatures = ''; } const authWindow = G.window.open(url, "GoogleAuthPopup", windowFeatures); if (!authWindow) { // This method should be invoked by an user action. throw new Error("This method should be invoked synchronously"); } return authWindow; } /** * Generates Google Auth endpoint (exposed by Grist) url. For example: * https://docs.getgrist.com/auth/google * @param scope Requested access scope for Google Services: * https://developers.google.com/identity/protocols/oauth2/scopes */ function getGoogleAuthEndpoint(scope: string) { return new URL(`auth/google?scope=${scope}`, window.location.origin).href; }