mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
291bcd17ff
Summary: Google Auth popup wasn't able to resolve origin from gristConfig. Moving this reponsability to server side, where it gets calculated from initial request. Test Plan: n/a Reviewers: dsagal, paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2935
178 lines
8.6 KiB
TypeScript
178 lines
8.6 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 * 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)
|
|
*
|
|
* 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";
|
|
|
|
// "View and manage Google Drive files and folders that you have opened or created with this app.""
|
|
export const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file';
|
|
|
|
// 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 (!req.query.code) {
|
|
throw new ApiError("Google Auth endpoint requires a code parameter in the query string", 400);
|
|
} else {
|
|
try {
|
|
const oAuth2Client = _googleAuthClient();
|
|
// Decrypt code that was send back from Google Auth service. Uses GOOGLE_CLIENT_SECRET key.
|
|
const tokenResponse = await oAuth2Client.getToken(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.error("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 (req.query.code) {
|
|
log.debug("GoogleAuth - response from Google with valid code");
|
|
messagePage(req, res, { code: req.query.code, origin: req.query.state });
|
|
} else if (req.query.error) {
|
|
log.debug("GoogleAuth - response from Google with error code", req.query.error);
|
|
if (req.query.error === "access_denied") {
|
|
messagePage(req, res, { error: req.query.error, origin: 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 = _googleAuthClient();
|
|
const scope = req.query.scope || DRIVE_SCOPE;
|
|
// Create url for origin parameter for a popup window.
|
|
const origin = `${req.protocol}://${req.headers.host}`;
|
|
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.
|
|
*/
|
|
function _googleAuthClient() {
|
|
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;
|
|
}
|