diff options
Diffstat (limited to 'src/server/apis/google/GoogleApiServerUtils.ts')
-rw-r--r-- | src/server/apis/google/GoogleApiServerUtils.ts | 353 |
1 files changed, 244 insertions, 109 deletions
diff --git a/src/server/apis/google/GoogleApiServerUtils.ts b/src/server/apis/google/GoogleApiServerUtils.ts index 6093197f1..b9984649e 100644 --- a/src/server/apis/google/GoogleApiServerUtils.ts +++ b/src/server/apis/google/GoogleApiServerUtils.ts @@ -1,61 +1,175 @@ import { google } from "googleapis"; -import { createInterface } from "readline"; -import { readFile, writeFile } from "fs"; -import { OAuth2Client, Credentials } from "google-auth-library"; +import { readFile } from "fs"; +import { OAuth2Client, Credentials, OAuth2ClientOptions } from "google-auth-library"; import { Opt } from "../../../new_fields/Doc"; import { GlobalOptions } from "googleapis-common"; import { GaxiosResponse } from "gaxios"; import request = require('request-promise'); import * as qs from 'query-string'; -import Photos = require('googlephotos'); import { Database } from "../../database"; -const path = require("path"); +import path from "path"; /** - * Server side authentication for Google Api queries. + * + */ +const prefix = 'https://www.googleapis.com/auth/'; + +/** + * + */ +const refreshEndpoint = "https://oauth2.googleapis.com/token"; + +/** + * + */ +const SCOPES = [ + 'documents.readonly', + 'documents', + 'presentations', + 'presentations.readonly', + 'drive', + 'drive.file', + 'photoslibrary', + 'photoslibrary.appendonly', + 'photoslibrary.sharing', + 'userinfo.profile' +]; + +/** + * This namespace manages server side authentication for Google API queries, either + * from the standard v1 APIs or the Google Photos REST API. */ export namespace GoogleApiServerUtils { - // If modifying these scopes, delete token.json. - const prefix = 'https://www.googleapis.com/auth/'; - const SCOPES = [ - 'documents.readonly', - 'documents', - 'presentations', - 'presentations.readonly', - 'drive', - 'drive.file', - 'photoslibrary', - 'photoslibrary.appendonly', - 'photoslibrary.sharing', - 'userinfo.profile' - ]; - - export const parseBuffer = (data: Buffer) => JSON.parse(data.toString()); + /** + * + */ + export interface CredentialsResult { + credentials: Credentials; + refreshed: boolean; + } + + /** + * + */ + export interface UserInfo { + at_hash: string; + aud: string; + azp: string; + exp: number; + family_name: string; + given_name: string; + iat: number; + iss: string; + locale: string; + name: string; + picture: string; + sub: string; + } + /** + * + */ export enum Service { Documents = "Documents", Slides = "Slides" } + /** + * + */ export interface CredentialInformation { credentialsPath: string; userId: string; } + /** + * + */ + let installed: OAuth2ClientOptions; + + /** + * This is a global authorization client that is never + * passed around, and whose credentials are never set. + * Its job is purely to generate new authentication urls + * (users will follow to get to Google's permissions GUI) + * and to use the codes returned from that process to generate the + * initial credentials. + */ + let worker: OAuth2Client; + + /** + * + */ export type ApiResponse = Promise<GaxiosResponse>; + + /** + * + */ export type ApiRouter = (endpoint: Endpoint, parameters: any) => ApiResponse; + + /** + * + */ export type ApiHandler = (parameters: any, methodOptions?: any) => ApiResponse; + + /** + * + */ export type Action = "create" | "retrieve" | "update"; - export type Endpoint = { get: ApiHandler, create: ApiHandler, batchUpdate: ApiHandler }; + /** + * + */ + export interface Endpoint { + get: ApiHandler; + create: ApiHandler; + batchUpdate: ApiHandler; + } + + /** + * + */ export type EndpointParameters = GlobalOptions & { version: "v1" }; - export const GetEndpoint = (sector: string, paths: CredentialInformation) => { + /** + * + */ + export const loadClientSecret = async () => { + return new Promise<void>((resolve, reject) => { + readFile(path.join(__dirname, "../../credentials/google_docs_credentials.json"), async (err, projectCredentials) => { + if (err) { + reject(err); + return console.log('Error loading client secret file:', err); + } + const { client_secret, client_id, redirect_uris } = JSON.parse(projectCredentials.toString()).installed; + // initialize the global authorization client + installed = { + clientId: client_id, + clientSecret: client_secret, + redirectUri: redirect_uris[0] + }; + worker = generateClient(); + resolve(); + }); + }); + }; + + /** + * + */ + const authenticationClients = new Map<String, OAuth2Client>(); + + /** + * + * @param sector + * @param userId + */ + export const GetEndpoint = (sector: string, userId: string) => { return new Promise<Opt<Endpoint>>(resolve => { - RetrieveCredentials(paths).then(authentication => { + retrieveOAuthClient(userId).then(auth => { let routed: Opt<Endpoint>; - let parameters: EndpointParameters = { auth: authentication.client, version: "v1" }; + let parameters: EndpointParameters = { auth, version: "v1" }; switch (sector) { case Service.Documents: routed = google.docs(parameters).documents; @@ -69,53 +183,95 @@ export namespace GoogleApiServerUtils { }); }; - export const RetrieveAccessToken = (information: CredentialInformation) => { + /** + * + * @param userId + */ + export const retrieveAccessToken = (userId: string): Promise<string> => { return new Promise<string>((resolve, reject) => { - RetrieveCredentials(information).then( - credentials => resolve(credentials.token.access_token!), + retrieveCredentials(userId).then( + ({ credentials }) => resolve(credentials.access_token!), error => reject(`Error: unable to authenticate Google Photos API request.\n${error}`) ); }); }; - let AuthorizationManager: OAuth2Client; - - export const LoadOAuthClient = async () => { - return new Promise<void>((resolve, reject) => { - readFile(path.join(__dirname, "../../credentials/google_docs_credentials.json"), async (err, credentials) => { - if (err) { - reject(err); - return console.log('Error loading client secret file:', err); - } - const { client_secret, client_id, redirect_uris } = parseBuffer(credentials).installed; - AuthorizationManager = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); - resolve(); - }); + /** + * + * @param userId + */ + export const retrieveOAuthClient = (userId: string): Promise<OAuth2Client> => { + return new Promise<OAuth2Client>((resolve, reject) => { + retrieveCredentials(userId).then( + ({ credentials, refreshed }) => { + let client = authenticationClients.get(userId); + if (!client) { + authenticationClients.set(userId, client = generateClient(credentials)); + } else if (refreshed) { + client.setCredentials(credentials); + } + resolve(client); + }, + error => reject(`Error: unable to instantiate and certify a new OAuth2 client.\n${error}`) + ); }); }; - export const GenerateAuthenticationUrl = async (information: CredentialInformation) => { - return AuthorizationManager.generateAuthUrl({ + /** + * + * @param credentials + */ + function generateClient(credentials?: Credentials) { + const client = new google.auth.OAuth2(installed); + credentials && client.setCredentials(credentials); + return client; + } + + /** + * + */ + export const generateAuthenticationUrl = async () => { + return worker.generateAuthUrl({ access_type: 'offline', scope: SCOPES.map(relative => prefix + relative), }); }; + /** + * This is what we return to the server in processNewUser(), after the + * worker OAuth2Client has used the user-pasted authentication code + * to retrieve an access token and an info token. The avatar is the + * URL to the Google-hosted mono-color, single white letter profile 'image'. + */ export interface GoogleAuthenticationResult { access_token: string; avatar: string; name: string; } - export const ProcessClientSideCode = async (information: CredentialInformation, authenticationCode: string): Promise<GoogleAuthenticationResult> => { + + /** + * This method receives the authentication code that the + * user pasted into the overlay in the client side and uses the worker + * and the authentication code to fetch the full set of credentials that + * we'll store in the database for each user. This is called once per + * new account integration. + * @param userId The Dash user id of the user requesting account integration, used to associate the new credentials + * with a Dash user in the googleAuthentication table of the database. + * @param authenticationCode the Google-provided authentication code that the user copied + * from Google's permissions UI and pasted into the overlay. + * @returns the information necessary to authenticate a client side google photos request + * and display basic user information in the overlay on successful authentication. + * This can be expanded as needed by adding properties to the interface GoogleAuthenticationResult. + */ + export const processNewUser = async (userId: string, authenticationCode: string): Promise<GoogleAuthenticationResult> => { return new Promise<GoogleAuthenticationResult>((resolve, reject) => { - AuthorizationManager.getToken(authenticationCode, async (err, token) => { - if (err || !token) { + worker.getToken(authenticationCode, async (err, credentials) => { + if (err || !credentials) { reject(err); return console.error('Error retrieving access token', err); } - AuthorizationManager.setCredentials(token); - const enriched = injectUserInfo(token); - await Database.Auxiliary.GoogleAuthenticationToken.Write(information.userId, enriched); + const enriched = injectUserInfo(credentials); + await Database.Auxiliary.GoogleAuthenticationToken.Write(userId, enriched); const { given_name, picture } = enriched.userInfo; resolve({ access_token: enriched.access_token!, @@ -127,86 +283,66 @@ export namespace GoogleApiServerUtils { }; /** + * This type represents the union of the full set of OAuth2 credentials + * and all of a Google user's publically available information. This is the strucure + * of the JSON object we ultimately store in the googleAuthentication table of the database. + */ + export type EnrichedCredentials = Credentials & { userInfo: UserInfo }; + + /** * It's pretty cool: the credentials id_token is split into thirds by periods. * The middle third contains a base64-encoded JSON string with all the * user info contained in the interface below. So, we isolate that middle third, * base64 decode with atob and parse the JSON. * @param credentials the client credentials returned from OAuth after the user * has executed the authentication routine + * @returns the full set of credentials in the structure in which they'll be stored + * in the database. */ const injectUserInfo = (credentials: Credentials): EnrichedCredentials => { const userInfo = JSON.parse(atob(credentials.id_token!.split(".")[1])); return { ...credentials, userInfo }; }; - export type EnrichedCredentials = Credentials & { userInfo: UserInfo }; - export interface UserInfo { - at_hash: string; - aud: string; - azp: string; - exp: number; - family_name: string; - given_name: string; - iat: number; - iss: string; - locale: string; - name: string; - picture: string; - sub: string; - } - - export const RetrieveCredentials = (information: CredentialInformation) => { - return new Promise<TokenResult>((resolve, reject) => { - readFile(information.credentialsPath, async (err, credentials) => { - if (err) { - reject(err); - return console.log('Error loading client secret file:', err); - } - authorize(parseBuffer(credentials), information.userId).then(resolve, reject); - }); - }); - }; - - export const RetrievePhotosEndpoint = (paths: CredentialInformation) => { - return new Promise<any>((resolve, reject) => { - RetrieveAccessToken(paths).then( - token => resolve(new Photos(token)), - reject - ); - }); - }; - - type TokenResult = { token: Credentials, client: OAuth2Client }; /** - * Create an OAuth2 client with the given credentials, and returns the promise resolving to the authenticated client - * @param {Object} credentials The authorization client credentials. + * Looks in the database for any credentials object with the given user id, + * and returns them. If the credentials are found but expired, the function will + * automatically refresh the credentials and then resolve with the updated values. + * @param userId the id of the Dash user requesting his/her credentials. Eventually + * might have multiple. + * @returns the credentials and whether or not they were updated in the process */ - export function authorize(credentials: any, userId: string): Promise<TokenResult> { - const { client_secret, client_id, redirect_uris } = credentials.installed; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); - return new Promise<TokenResult>((resolve, reject) => { - // Attempting to authorize user (${userId}) - Database.Auxiliary.GoogleAuthenticationToken.Fetch(userId).then(token => { - if (token!.expiry_date! < new Date().getTime()) { + const retrieveCredentials = async (userId: string): Promise<CredentialsResult> => { + return new Promise<CredentialsResult>((resolve, reject) => { + Database.Auxiliary.GoogleAuthenticationToken.Fetch(userId).then(credentials => { + if (!credentials) { + return reject(); + } + if (credentials.expiry_date! < new Date().getTime()) { // Token has expired, so submitting a request for a refreshed access token - return refreshToken(token!, client_id, client_secret, oAuth2Client, userId).then(resolve, reject); + return refreshAccessToken(credentials, userId).then(resolve, reject); } // Authentication successful! - oAuth2Client.setCredentials(token!); - resolve({ token: token!, client: oAuth2Client }); + resolve({ credentials, refreshed: false }); }); }); - } + }; - const refreshEndpoint = "https://oauth2.googleapis.com/token"; - const refreshToken = (credentials: Credentials, client_id: string, client_secret: string, oAuth2Client: OAuth2Client, userId: string) => { - return new Promise<TokenResult>(resolve => { + /** + * This function submits a request to OAuth with the local refresh token + * to revalidate the credentials for a given Google user associated with + * the Dash user id passed in. In addition to returning the credentials, it + * writes the diff to the database. + * @param credentials the credentials + * @param userId + */ + const refreshAccessToken = (credentials: Credentials, userId: string) => { + return new Promise<CredentialsResult>(resolve => { let headerParameters = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }; let queryParameters = { refreshToken: credentials.refresh_token, - client_id, - client_secret, - grant_type: "refresh_token" + grant_type: "refresh_token", + ...installed }; let url = `${refreshEndpoint}?${qs.stringify(queryParameters)}`; request.post(url, headerParameters).then(async response => { @@ -215,8 +351,7 @@ export namespace GoogleApiServerUtils { await Database.Auxiliary.GoogleAuthenticationToken.Update(userId, access_token, expiry_date); credentials.access_token = access_token; credentials.expiry_date = expiry_date; - oAuth2Client.setCredentials(credentials); - resolve({ token: credentials, client: oAuth2Client }); + resolve({ credentials, refreshed: true }); }); }); }; |