gristlabs_grist-core/app/client/ui/googleAuth.ts

160 lines
6.5 KiB
TypeScript
Raw Permalink Normal View History

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;
}