mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
42910cb8f7
Summary: New environmental variable GOOGLE_DRIVE_SCOPE that modifies the scope requested for Google Drive integration. For prod it has value https://www.googleapis.com/auth/drive.file which leaves current behavior (Grist is allowed only to access public files and for private files - it fallbacks to Picker). For staging it has value https://www.googleapis.com/auth/drive.readonly which allows Grist to access all private files, and fallbacks to Picker only when the file is neither public nor private). Default value is https://www.googleapis.com/auth/drive.file Test Plan: manual and existing tests Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3038
160 lines
6.5 KiB
TypeScript
160 lines
6.5 KiB
TypeScript
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<string>((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;
|
|
}
|