gristlabs_grist-core/app/server/lib/GoogleAuth.ts
Jarosław Sadziński 42910cb8f7 (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
2021-10-01 10:47:12 +02:00

179 lines
8.9 KiB
TypeScript

import {auth} from '@googleapis/oauth2';
import {ApiError} from 'app/common/ApiError';
import {parseSubdomain} from 'app/common/gristUrls';
import {expressWrap} from 'app/server/lib/expressWrap';
import * as log from 'app/server/lib/log';
import {getOriginUrl, optStringParam, stringParam} from 'app/server/lib/requestUtils';
import * as express from 'express';
import {URL} from 'url';
/**
* Google Auth Endpoint for performing server side authentication. More information can be found
* at https://developers.google.com/identity/protocols/oauth2/web-server.
*
* Environmental variables used:
* - GOOGLE_CLIENT_ID : key obtained from a Google Project, not secret can be shared publicly,
* the same client id is used in Google Drive Plugin
* - GOOGLE_CLIENT_SECRET: secret key for Google Project, can't be shared - it is used to (d)encrypt
* data that we obtain from Google Auth Service (all done in the api)
* - GOOGLE_DRIVE_SCOPE: scope requested for the Google drive integration (defaults to drive.file which allows
* to create files and get files via Google Drive Picker)
*
* High level description:
*
* Each API endpoint that wants to talk to Google Api needs an access_token that identifies our application
* and permits us to access some of the user data or work with the API on the user's behalf (examples for that are
* Google Drive plugin and Google Export endpoint, [Send to Google Drive] feature on the UI).)
* To obtain this token on the server-side, the application needs to redirect the user to a
* Google Consent Screen - where the user can log into his account and give consent for the permissions
* we need. Permissions are defined by SCOPES - that exactly describes what we are allowed to do.
* More on scopes can be read on https://developers.google.com/identity/protocols/oauth2/scopes.
* When we are redirecting the user to a Google Consent Screen, we are also sending a static URL for an endpoint
* where Google will redirect the user after he gives us permissions or declines our request. For that, we are exposing
* static URL http://docs.getgrist.com/auth/google (on prod) or http://localhost:8080/auth/google (dev).
*
* NOTE: Actually, we are exposing only auth/google endpoint that can be accessed in various ways, including any
* subdomain, but Google will always redirect to the configured endpoint (example: http://docs.getgrist.com/auth/google)
*
* This endpoint will render a simple page (see /static/message.html) that will immediately post
* a message to the parent window with a response from Google in the form of { code? : string, error?: string }.
* Code returned from Google will be an encrypted access_token that the client-side code should use to invoke
* the server-side API endpoint that wishes to call one of the Google API endpoints. A server-side endpoint can use
* "googleAuthTokenMiddleware" middleware to convert code to access_token (by making a separate call to Google
* for exchanging code for an access_token).
* This access_token could be stored in the user's session for further use, but since we are making only a single call
* very rarely, and access_token will expire eventually; it is better to acquire access_token every time.
* More on storing access_token offline can be read on:
* https://developers.google.com/identity/protocols/oauth2/web-server#obtainingaccesstokens
*
* How to use:
*
* To call server-side endpoint that expects access_token, first decorate it with "googleAuthTokenMiddleware"
* middleware, then perform "server-side" authentication with Google on the client-side to acquire an encrypted token.
* Client code should open up a popup window with an URL to Grist's Google Auth endpoint (/auth/google) and wait
* for a message from a popup window containing an encrypted token.
* Having encrypted token ("code"), a client can call the server-side endpoint by adding to the query string the code
* acquired from the popup window. Server endpoint (decorated by "googleAuthTokenMiddleware") will get access_token
* in a query string.
*/
// Path for the auth endpoint.
const authHandlerPath = "/auth/google";
// Redirect host after the Google Auth login form is completed. This reuses the same domain name
// as for Cognito login.
const AUTH_SUBDOMAIN = process.env.GRIST_ID_PREFIX ? `docs-${process.env.GRIST_ID_PREFIX}` : 'docs';
/**
* Return a full url for Google Auth handler. Examples are:
*
* http://localhost:8080/auth/google in dev
* https://docs-s.getgrist.com in staging
* https://docs.getgrist.com in prod
*/
function getFullAuthEndpointUrl(): string {
const homeUrl = process.env.APP_HOME_URL;
// if homeUrl is localhost - (in dev environment) - use the development url
if (homeUrl && new URL(homeUrl).hostname === "localhost") {
return `${homeUrl}${authHandlerPath}`;
}
const homeBaseDomain = homeUrl && parseSubdomain(new URL(homeUrl).host).base;
const baseDomain = homeBaseDomain || '.getgrist.com';
return `https://${AUTH_SUBDOMAIN}${baseDomain}${authHandlerPath}`;
}
/**
* Middleware for obtaining access_token from Google Auth Service.
* It expects code query parameter (provided by frontend code) add adds to access_token to query parameters.
*/
export async function googleAuthTokenMiddleware(
req: express.Request,
res: express.Response,
next: express.NextFunction) {
// If access token is in place, proceed
if (!optStringParam(req.query.code)) {
throw new ApiError("Google Auth endpoint requires a code parameter in the query string", 400);
} else {
try {
const oAuth2Client = getGoogleAuth();
// Decrypt code that was send back from Google Auth service. Uses GOOGLE_CLIENT_SECRET key.
const tokenResponse = await oAuth2Client.getToken(stringParam(req.query.code));
// Get the access token (access token will be present in a default request configuration).
const access_token = tokenResponse.tokens.access_token!;
req.query.access_token = access_token;
next();
} catch (err) {
log.error("GoogleAuth - Error", err);
throw err;
}
}
}
/**
* Adds a static Google Auth endpoint. This will be used by Google Auth Service to redirect back, after the user
* finishes a signing and a consent flow. Google will pass 2 arguments in a query string:
* - code: encrypted access token when user gave permissions
* - error: error code when user declined our request.
*/
export function addGoogleAuthEndpoint(
expressApp: express.Application,
messagePage: (req: express.Request, res: express.Response, message: any) => any
) {
if (!process.env.GOOGLE_CLIENT_SECRET) {
log.warn("Failed to create GoogleAuth endpoint: GOOGLE_CLIENT_SECRET is not defined");
expressApp.get(authHandlerPath, expressWrap(async (req: express.Request, res: express.Response) => {
throw new Error("Send to Google Drive is not configured.");
}));
return;
}
log.info(`GoogleAuth - auth handler at ${getFullAuthEndpointUrl()}`);
expressApp.get(authHandlerPath, expressWrap(async (req: express.Request, res: express.Response) => {
// Test if the code is in a query string. Google sends it back after user has given a concent for
// our request. It is encrypted (with CLIENT_SECRET) and signed with redirect url.
// In state query parameter we will receive an url that was send as part of the request to Google.
if (optStringParam(req.query.code)) {
log.debug("GoogleAuth - response from Google with valid code");
messagePage(req, res, { code: stringParam(req.query.code),
origin: stringParam(req.query.state) });
} else if (optStringParam(req.query.error)) {
log.debug("GoogleAuth - response from Google with error code", stringParam(req.query.error));
if (stringParam(req.query.error) === "access_denied") {
messagePage(req, res, { error: stringParam(req.query.error),
origin: stringParam(req.query.state) });
} else {
// This should not happen, either code or error is a mandatory query parameter.
throw new ApiError("Error authenticating with Google", 500);
}
} else {
const oAuth2Client = getGoogleAuth();
const scope = stringParam(req.query.scope);
// Create url for origin parameter for a popup window.
const origin = getOriginUrl(req);
const authUrl = oAuth2Client.generateAuthUrl({
scope,
prompt: 'select_account',
state: origin
});
log.debug(`GoogleAuth - redirecting to Google consent screen`, {
authUrl,
scope,
state: origin
});
res.redirect(authUrl);
}
}));
}
/**
* Builds the OAuth2 Google client.
*/
export function getGoogleAuth() {
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const oAuth2Client = new auth.OAuth2(CLIENT_ID, CLIENT_SECRET, getFullAuthEndpointUrl());
return oAuth2Client;
}