liveperson/frontend/index.js (155 lines of code) (raw):
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ClientOAuth2 from 'client-oauth2';
import timeout from 'connect-timeout';
import cookieParser from 'cookie-parser';
import { createHmac } from 'crypto';
import dotenv from 'dotenv';
import express from 'express';
import fetch from 'node-fetch';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import { uuid } from 'uuidv4';
dotenv.config();
const app = express();
const __dirname = dirname(fileURLToPath(import.meta.url));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Set 30s timeout for all requests.
// Following instructions from
// https://github.com/expressjs/timeout#as-top-level-middleware)
app.use(timeout('30s'));
app.use(express.json());
app.use(haltOnTimedout);
app.use(cookieParser());
app.use(haltOnTimedout);
function haltOnTimedout(req, res, next) {
if (!req.timedout) next();
}
const {
LP_SENTINEL_DOMAIN,
LP_CLIENT_ID,
LP_ACCOUNT_ID,
LP_CLIENT_SECRET,
APPLICATION_SERVER_URL,
DF_PROXY_SERVER_URL,
SECRET_PHRASE,
} = process.env;
const LP_AUTHORIZATION_URI = `https://${LP_SENTINEL_DOMAIN}/sentinel/api/account/${LP_ACCOUNT_ID}/authorize?v=1.0`;
const LP_ACCESS_TOKEN_URI = `https://${LP_SENTINEL_DOMAIN}/sentinel/api/account/${LP_ACCOUNT_ID}/token?v=1.0`;
const client = new ClientOAuth2({
clientId: LP_CLIENT_ID,
clientSecret: LP_CLIENT_SECRET,
redirectUri: `${APPLICATION_SERVER_URL}/home`,
authorizationUri: LP_AUTHORIZATION_URI,
accessTokenUri: LP_ACCESS_TOKEN_URI,
});
const getHmacHasher = () => createHmac('sha256', SECRET_PHRASE);
const getAuthEntryPoint = state => {
const entryPointUrl = new URL(APPLICATION_SERVER_URL);
entryPointUrl.searchParams.set(
'conversationProfile',
state.conversationProfile
);
entryPointUrl.searchParams.set('features', state.features);
return entryPointUrl.toString();
};
/**
* Entry point for the LivePerson OAuth flow.
*
* This is the 'entry_uri' specified in the Conversational Cloud app
* registration.
*/
app.get('/', (req, res) => {
const { conversationProfile, features } = req.query;
// A random ID to associate with this request. This will be included
// in the request as well as cached in the client browser. When the OAuth flow
// is finished redirecting, we will verify that the two IDs match.
const requestId = uuid();
const hashedRequestId = getHmacHasher().update(requestId).digest('hex');
const state = Buffer.from(
JSON.stringify({
conversationProfile,
features,
requestId: hashedRequestId,
})
).toString('base64url');
res.setHeader('Set-Cookie', `requestId=${requestId}; SameSite=None; Secure`);
const redirectUri = client.code.getUri({ state });
res.redirect(redirectUri);
});
/**
* UI Modules application home page.
*
* This is the 'redirect_uri' specified in the Conversational Cloud app
* registration. User will be redirected here once they have been authenticated
* using their LivePerson credentials.
*/
app.get('/home', async (req, res) => {
const code = String(req.query.code || '');
const error = String(req.query.error || '');
const state = String(req.query.state || '');
const { requestId } = req.cookies;
const hashedRequestId = getHmacHasher().update(requestId).digest('hex');
let decodedState;
try {
decodedState = JSON.parse(
Buffer.from(state, 'base64url').toString('ascii')
);
if (!decodedState.conversationProfile || !decodedState.features) {
throw new Error();
}
} catch {
res
.status(500)
.send(
'Invalid request. Please specify a valid conversation profile and ' +
"features list in your entrypoint URL's query parameters.\n\n" +
'Example: https://my-application.com?conversationProfile=projects/foo/locations/global/conversationProfiles/bar&features=SMART_REPLY,ARTICLE_SUGGESTION'
);
return;
}
if (decodedState.requestId !== hashedRequestId) {
res
.status(500)
.send('Invalid request. Please clear your cookies and try again.');
return;
}
const authEntryPoint = getAuthEntryPoint(decodedState);
// If no code or error is present, redirect user to the standard auth flow.
if (!code && !error) {
res.redirect(authEntryPoint);
return;
}
res.render('home');
});
/**
* Fetches a LivePerson authentication token using the authorization code from
* the OAuth redirect.
*/
app.get('/auth', async (req, res) => {
let authEntryPoint;
const { referer } = req.headers;
if (!referer) {
const errorMessage = 'No referer header in request';
console.log('[ERROR] [Application server] [/auth]: ', errorMessage);
res.status(500).json({ error: errorMessage });
return;
}
try {
const redirectUri = new URL(referer);
const decodedState = JSON.parse(
Buffer.from(redirectUri.searchParams.get('state'), 'base64url').toString(
'ascii'
)
);
authEntryPoint = getAuthEntryPoint(decodedState);
const tokenResponse = await fetch(`${DF_PROXY_SERVER_URL}/auth/token`, {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
redirectUri: `${
redirectUri.pathname
}?${redirectUri.searchParams.toString()}`,
}),
});
if (tokenResponse.status === 200) {
const { accessToken, refreshToken } = await tokenResponse.json();
res.json({
state: decodedState,
proxyServer: DF_PROXY_SERVER_URL,
accessToken,
refreshToken,
});
} else {
const { error } = await tokenResponse.json();
console.log('[ERROR] [Application server] [/auth]: ', error);
res.status(tokenResponse.status).json({ error, authEntryPoint });
}
} catch (error) {
const errorMessage = error.toString();
console.log('[ERROR] [Application server] [/auth]: ', errorMessage);
res.status(500).json({
error: errorMessage,
authEntryPoint,
});
}
});
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});