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/D3038pull/115/head
parent
a0c53f2b61
commit
42910cb8f7
@ -0,0 +1,159 @@
|
||||
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;
|
||||
}
|
Loading…
Reference in new issue