(core) Extending Google Drive integration scope

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
This commit is contained in:
Jarosław Sadziński
2021-09-30 10:19:22 +02:00
parent a0c53f2b61
commit 42910cb8f7
13 changed files with 302 additions and 131 deletions

View File

@@ -1,30 +1,18 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {reportError} from 'app/client/models/errors';
import {spinnerModal} from 'app/client/ui2018/modals';
import type { DocPageModel } from 'app/client/models/DocPageModel';
import type { Document } from 'app/common/UserAPI';
import type { Disposable } from 'grainjs';
import type {DocPageModel} from 'app/client/models/DocPageModel';
import type {Document} from 'app/common/UserAPI';
import { getGoogleCodeForSending } from "app/client/ui/googleAuth";
const G = getBrowserGlobals('window');
/**
* 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
* Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access
* token, then it calls "send-to-drive" api endpoint to upload xlsx file to drive and finally it
* redirects to the created spreadsheet. Code that is received from Google contains encrypted access
* token, server is able to decrypt it using GOOGLE_CLIENT_SECRET key.
*/
function getGoogleAuthEndpoint(scope?: string) {
return new URL(`auth/google?scope=${scope || ''}`, window.location.origin).href;
}
/**
* Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access token,
* then it calls "send-to-drive" api endpoint to upload xlsx file to drive and finally it redirects
* to the created spreadsheet.
* Code that is received from Google contains encrypted access token, server is able to decrypt it
* using GOOGLE_CLIENT_SECRET key.
*/
export function sendToDrive(doc: Document, pageModel: DocPageModel) {
export async function sendToDrive(doc: Document, pageModel: DocPageModel) {
// Get current document - it will be used to remove popup listener.
const gristDoc = pageModel.gristDoc.get();
// Sanity check - gristDoc should be always present
@@ -38,68 +26,11 @@ export function sendToDrive(doc: Document, pageModel: DocPageModel) {
.sendToDrive(code, pageModel.currentDocTitle.get())
);
// Compute google auth server endpoint (grist endpoint for server side google authentication).
// This endpoint will redirect user to Google Consent screen and after Google sends a response,
// it will render a page (/static/message.html) that will post a message containing message
// from Google. Message will be an object { code, error }. We will use the code to invoke
// "send-to-drive" api endpoint - that will actually send the xlsx file to Google Drive.
const authLink = getGoogleAuthEndpoint();
const authWindow = openPopup(authLink);
attachListener(gristDoc, authWindow, async (event: MessageEvent) => {
// For the first message (we expect only a single message) close the window.
authWindow.close();
// Check response from the popup
const response = (event.data || {}) as { code?: string, error?: string };
// - when user declined, do nothing,
if (response.error === "access_denied") { return; }
// - when there is no response code or error code is different from what we expected - report to user.
if (!response.code) { reportError(response.error || "Unrecognized or empty error code"); return; }
// Send file to Google Drive.
try {
const { url } = await send(response.code);
G.window.location.assign(url);
} catch (err) {
reportError(err);
}
});
}
// Helper function that attaches a handler to message event from a popup window.
function attachListener(owner: Disposable, popup: Window, listener: (e: MessageEvent) => any) {
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);
};
G.window.addEventListener('message', wrapped);
owner.onDispose(() => {
G.window.removeEventListener('message', wrapped);
});
}
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 = '';
try {
const token = await getGoogleCodeForSending(gristDoc);
const {url} = await send(token);
G.window.location.assign(url);
} catch (err) {
reportError(err);
}
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;
}