Skip to content

Commit

Permalink
Add corrections
Browse files Browse the repository at this point in the history
  • Loading branch information
edevalais-medallia committed Sep 8, 2021
1 parent 8a4754d commit 12698a6
Show file tree
Hide file tree
Showing 10 changed files with 96 additions and 119 deletions.
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ WORKDIR /usr/src/app
COPY ./package.json /usr/src/app/package.json
COPY ./package-lock.json /usr/src/app/package-lock.json

ENV SHARED_SECRET=<32_CHARACTER_STRING>
ENV CONVO_INSTANCE_URL=https://<MEDALLIA_CONVERSATION_HOST>
ENV CONVO_WEBHOOK_URL=https://<MEDALLIA_CONVERSATION_HOST>/cg/mc/custom/<CHANNEL_GUID>
ENV CONVO_API_GATEWAY=https://<MEDALLIA_CONVERSATION_HOST>
ENV CHANNEL_GUID=<CHANNEL_GUID>
ENV CLIENT_ID=<CLIENT_ID>
ENV CLIENT_SECRET=<CLIENT_SECRET>
ENV AUTH_TYPE_OUTBOUND=<AUTH_TYPE_OUTBOUND>
ENV ACCESS_TOKEN=<ACCESS_TOKEN_FOR_API_TOKEN>
ENV DEFAULT_OAUTH_EXPIRES_SECS=<DEFAULT_OAUTH_EXPIRES_SECS>

RUN env && npm ci --loglevel warn

Expand Down
68 changes: 37 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Medallia Conversations Adapter

This project is a reference implementation of a skeleton adapter to receive
and deliver messages with Medallia Conversations. Developed and maintained
as a standalone app in order to leverage the Message Connector API
Expand All @@ -19,46 +20,50 @@ On a host machine with hostname/IP of <ADAPTER_HOST>, run the following
sequence of commands:

1. `git clone <where ever we host the skeleton>`
2. Set environment variable:
* `export CONVO_WEBHOOK_URL=<MEDALLIA_CONVERSATION_HOST>/cg/mc/custom/<CHANNEL_GUID>`
1. For conversations inbound messages
* `export AUTH_TYPE_INBOUND=<It can be 'Oauth2' or 'Signature'>`
* `export SHARED_SECRET=<32_CHARACTER_STRING only for Signature>`
* `export CONVO_INSTANCE_URL=<MEDALLIA_CONVERSATION_HOST>/oauth/token`
* `export CLIENT_ID=<CLIENT ID only for Oauth2>`
* `export CLIENT_SECRET=<Secret only for Oauth2>`
2. For conversations outbound messages
* `export AUTH_TYPE_OUTBOUND=<It can be 'Oauth2' or 'API-Token'>`
* `export ACCESS_TOKEN=<STRING that is configured in the channel only for API-Token auth type>`
2. Set environment variable
* `export CONVO_API_GATEWAY=https://<MEDALLIA_CONVERSATIONS_HOST>`
* `export CHANNEL_GUID=<Channel GUID>`
* `export DEFAULT_OAUTH_EXPIRES_SECS=<OAuth expiration time in sec>`
1. For communicating from the channel adapter to Medallia Conversations,
* `export CLIENT_ID=<CLIENT ID for OAuth>`
* `export CLIENT_SECRET=<Secret for OAuth>`
2. For communicating from Medallia Conversations to the channel adapter,
* `export AUTH_TYPE_OUTBOUND=<It can be 'OAuth' or 'API-Token'>`
* `export ACCESS_TOKEN=<STRING that is configured in the channel only for API-Token authentication type>`
3. `npm install`
4. `npm start`

This will start a service on port 1338.

### Auth Configuration
For inbound conversations configuration you can setup 2 auth types:
* Signature: This is used to generate signature of the body to send it to Medallia Conversations with the SHARED_SECRET key. In the converation side, under the Signed request auth type, this Secret should match. The string must be 32 characters long.
* Oauth2: It will use the Conversations OAuth server. You will need the following configuration CONVO_INSTANCE_URL, CLIENT_ID and CLIENT_SECRET.
* CONVO_INSTANCE_URL: <MEDALLIA_CONVERSATION_HOST>/oauth/token
* CLIENT_ID: Client ID from Conversation
* CLIENT_SECRET: Client secret from Conversation

For outbound conversations configuration you can setup 2 auth types:
* API-Token: This method will validate the header/query Token conversation sends against the ACCESS_TOKEN.
* Oauth2: It will use the Client OAuth Server. There is a Test server for this porpouse here and the default configuration you must set up in the conversations side is the following.
* OAuth2 server URL: http://<ADAPTER_HOST>:1338/token
### Authentication Configuration

For communicating from the channel adapter to Medallia Conversations,
* OAuth: It will use the Conversations OAuth server.
You will need the following configuration CLIENT_ID and CLIENT_SECRET environment variables
matching the ones from the Conversations Channel configuration,
* Client ID: Client ID from Conversations for OAuth
* Client secret: Client secret from Conversations for OAuth

For communicating from Medallia Conversations to the channel adapter
set the AUTH_TYPE_OUTBOUND to one of the following options,
* API-Token: This method will validate the header/query Token conversations sends against the ACCESS_TOKEN.
* OAuth: Authenticates with the client's OAuth 2.0 server using a client credentials grant.
Complete these configurations in the Conversations channel with the default values
* OAuth server URL: http://<ADAPTER_HOST>:1338/token
* Client ID: ConversationsClient
* Client secret: S3cr3t123!

All this configurations from the selected methods should match the Conversation's channel Auth settings.

### Docker

On the Docker host system, run the following sequence of commands:

On the Docker host system, run the following sequence of commands,
1. Edit [Dockerfile](Dockerfile):
* Change `<32_CHARACTER_STRING>` to a 32 character string.
* Change `<MEDALLIA_CONVERSATION_HOST>` to your Medallia Conversations host.
* Change `<MEDALLIA_CONVERSATIONS_HOST>` to your Medallia Conversations host.
* Change `<CHANNEL_GUID>` to your Medallia Conversations channel GUID.
* Change `<CLIENT_ID>` to your Medallia Conversations channel client ID.
* Change `<CLIENT_SECRET>` to your Medallia Conversations channel secret.
* Change `<AUTH_TYPE_OUTBOUND>` to 'API-Token' or 'OAuth'.
* Change `<ACCESS_TOKEN>` to your token if you are using API-Token.
* Change `<DEFAULT_OAUTH_EXPIRES_SECS>` to the default OAuth Expire time in secs.
2. `docker build -t skeleton .`
3. `docker run -p 1338:1338 -t skeleton`

Expand All @@ -72,7 +77,8 @@ This will start a service on port 1338.
2. *Page ID* A unique identification in the conversations system for your adapter.
* We are using `1234` in this example
3. *App ID* `1234`
4. Configure inbound and outbound auth settings following the recommendations from "Auth Configuration"
4. Configure inbound and outbound authentication settings following the recommendations
from [Authentication Configuration](#Authentication Configuration)
3. Create a conversation on your instance.
1. Add keyword `hello`
2. Create a dialog type `statement` with `Hello World!` in the
Expand Down Expand Up @@ -100,7 +106,7 @@ The adapter should return something like the following:

```
Receiving post to skeleton webhook: {"consumer_id":"1234user5678","page_id":"1234","text":"hello"}
sending text message to Convo: {"body":{"object":"page","entry":[{"id":"1234","time":1558473634322,"messaging":[{"sender":{"id":"1234user5678"},"recipient":{"id":"1234"},"timestamp":1558473634322,"message":{"mid":1558473634322,"text":"hello"}}]}]},"url":"https://<MEDALLIA_CONVERSATION_HOST>/cg/mc/custom/<CHANNEL_GUID>"}
sending text message to Convo: {"body":{"object":"page","entry":[{"id":"1234","time":1558473634322,"messaging":[{"sender":{"id":"1234user5678"},"recipient":{"id":"1234"},"timestamp":1558473634322,"message":{"mid":1558473634322,"text":"hello"}}]}]},"url":"https://<MEDALLIA_CONVERSATIONS_HOST>/cg/mc/custom/<CHANNEL_GUID>"}
response from sendText: OK
/me/messages called with: {"body":{"recipient":{"id":"1234user5678"},"message":{"text":"Hello World!"},"notification_type":"REGULAR"},"access_token":"<env.ACCESS_TOKEN>"}
Expand Down
25 changes: 11 additions & 14 deletions auth-settings.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
module.exports = {

// authTypeInbound can be 'OAuth2' or 'Signature'
authTypeInbound: process.env.AUTH_TYPE_INBOUND,
//OAuth config for inbound
// OAuth config for inbound
oauthConfig: {
tokenUrl: process.env.CONVO_INSTANCE_URL
|| `${process.env.CONVO_WEBHOOK_URL && new URL(process.env.CONVO_WEBHOOK_URL).origin}/oauth/token`,
tokenUrl: `${process.env.CONVO_API_GATEWAY}/oauth/token`,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
clientSecret: process.env.CLIENT_SECRET
},
//for authTypeInbound Signature
secret: process.env.SHARED_SECRET,

// authType can be 'OAuth2' or 'API-Token'
// authTypeOutbound can be 'OAuth' or 'API-Token'
authTypeOutbound: process.env.AUTH_TYPE_OUTBOUND,

// for outbound request API-Token verification (i.e. requests coming from MC)
// Default OAuth Expire time in secs
defaultOAuthExpiresSecs: process.env.DEFAULT_OAUTH_EXPIRES_SECS,

// For requests coming from Medallia Conversations with API-Token verification
accessToken: process.env.ACCESS_TOKEN,

// This is the OAuth client configuration (adapter acting as client to MC as OAuth server)
// This is for a dummy OAuth server, that will be used to issue this fixed access token
// and verify that MC sends it in the Authorization header
// This is the OAuth 2.0 configuration used by Medallia Conversations to connect with the channel adapter.
// This is for a dummy OAuth server that will be used to issue this fixed access token
// and verify that Medallia Conversations sends it in the Authorization header
oauthServer: {
tokenPath: '/token',
clients: {
Expand Down
5 changes: 2 additions & 3 deletions src/convoRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ router.post('/me/messages', (req, res) => {
})}`);

const authz = req.get('Authorization');
console.log('Authorization header value = ', authz);
let valid = false;
if (authz && authz.startsWith('Bearer')) {
const token = authz.split(' ')[1];
if(authSettings.authTypeOutbound === 'Oauth2'){
if (authSettings.authTypeOutbound === 'OAuth') {
valid = isTokenValid(token);
}else if(authSettings.authTypeOutbound === 'API-Token'){
} else if (authSettings.authTypeOutbound === 'API-Token') {
valid = _.isEqual(token, authSettings.accessToken);
}
console.log('Successfully verified Bearer access token');
Expand Down
8 changes: 8 additions & 0 deletions src/helpers/Cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const Cache = require('ttl');
const { defaultOAuthExpiresSecs } = require('../../auth-settings');

const cache = new Cache({
ttl: defaultOAuthExpiresSecs * 1000
});

module.exports = cache;
27 changes: 13 additions & 14 deletions src/helpers/OAuthClient.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
const got = require('got');
const Cache = require('ttl');
const qs = require('querystring');
const cache = require('./cache');
const { defaultOAuthExpiresSecs } = require('../../auth-settings');

const cache = new Cache({
ttl: 3600 * 1000
});
if (process.env.NODE_ENV !== 'production') {
cache.on('hit', (key, val) => {
console.log(`Cache hit for key: ${key}; Value: ${val}`);
console.log(`Cache hit for key ${key} Value ${val}`);
});
cache.on('miss', (key) => {
console.log(`Cache miss for key: ${key}`);
console.log(`Cache miss for key ${key}`);
});
cache.on('put', (key, val, ttl) => {
console.log(`Cache put for key: ${key}; Value: ${val} with ttl: ${ttl}`);
console.log(`Cache put for key ${key} Value ${val} with ttl ${ttl}`);
});
}

Expand All @@ -23,27 +21,28 @@ async function getAccessToken(authSettings) {
const { tokenUrl, clientId, clientSecret } = authSettings.oauthConfig;
token = cache.get(clientId);
if (!token) {
const payload = { grant_type: 'client_credentials', client_id: clientId };
const cred = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64');
console.log('Fetching new access token for client:', clientId, 'from token URL:', tokenUrl);
const payload = { grant_type: 'client_credentials'};
const oauthTokenRequestCredentials = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64');
console.log(`Fetching new access token for client ${clientId} from token URL ${tokenUrl}`);
try {
const { body } = await got.post(tokenUrl, {
body: qs.encode(payload),
responseType: 'json',
headers: {
Authorization: `Basic ${cred}`,
Authorization: `Basic ${oauthTokenRequestCredentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
});
console.log('Received /token response from MC: ', body);
console.log('Received /token response from Medallia Conversations: ', body);
const res = JSON.parse(body);
const expiresIn = res.expires_in || defaultOAuthExpiresSecs;
token = res.access_token;
cache.put(clientId, token, res.expires_in * 1000);
cache.put(clientId, token, expiresIn * 1000);
} catch (e) {
console.error(`Error fetching access token from ${tokenUrl}`, e);
}
} else {
console.log(`Returning cached token: ${token} for client: ${clientId}`);
console.log(`Returning cached token ${token} for client ${clientId}`);
}
}
return token;
Expand Down
26 changes: 9 additions & 17 deletions src/helpers/TestOAuthServer.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
// test OAuth server that supports only client_credentials grant type
// with a fixed set of client id and secret values configured in auth-settings.js
// This implements a basic OAuth 2.0-compatible token server for use with this reference implementation.
// It only supports client_credentials grants and uses the static client_id/client_secret values that are
// configured in auth-settings.js.

const basicAuth = require('express-basic-auth');
const crypto = require('crypto');
const router = require('express').Router();

const Cache = require('ttl');
const { oauthServer } = require('../../auth-settings');

const cache = new Cache({
ttl: 3600 * 1000
});
const { oauthServer, defaultOAuthExpiresSecs } = require('../../auth-settings');
const cache = require('./cache');

const staticAuth = basicAuth({
users: oauthServer.clients
Expand All @@ -23,19 +20,14 @@ router.post(oauthServer.tokenPath, staticAuth, (req, res) => {
} else {
const token = crypto.randomBytes(16).toString('hex');
const { auth } = req;
if (auth.user) cache.put(token, auth.user);
if (auth.user) {
cache.put(token, auth.user);
}
console.info(`Issued new access token: ${token} for client ${auth.user || 'unknown'}`);
res.status(200).send({ access_token: token, expires_in: 3600 });
res.status(200).send({ access_token: token, expires_in: defaultOAuthExpiresSecs });
}
});

// This is just to confirm the token is valid and get the client info for the token
router.get('/userInfo', (req, res) => {
const token = req.query.token;
const user = cache.get(token);
return user ? res.status(200).send({ user }) : res.sendStatus(400);
});

function isTokenValid(token) {
return cache.get(token) || false;
}
Expand Down
38 changes: 8 additions & 30 deletions src/helpers/convoApiFactory.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
const _ = require('lodash');
const oauthClient = require('./OAuthClient');
const generateSignature = require('../helpers/generateSignature');

function buildBody(senderId, pageId, inBody, type = 'message') {
console.log(`parameters to buildBody: ${JSON.stringify({
console.log(`parameters to buildBody ${JSON.stringify({
senderId, pageId, inBody, type
})}`);

Expand Down Expand Up @@ -47,11 +46,10 @@ function buildBody(senderId, pageId, inBody, type = 'message') {

async function getAuthorization(authSettings) {
let authzVal = null;
if (authSettings && authSettings.authTypeInbound === 'Oauth2') {
console.log('getting access token');
const token = await oauthClient.getAccessToken(authSettings);
authzVal = `Bearer ${token}`;
}
console.log('getting access token');
const token = await oauthClient.getAccessToken(authSettings);
authzVal = `Bearer ${token}`;

return authzVal;
}

Expand All @@ -65,44 +63,24 @@ async function sendPostRequest(got, url, authSettings, body) {
}
};
const authzVal = await getAuthorization(authSettings);
console.debug('Adding Authorization header value', authzVal);
if (authzVal) {
headers.Authorization = authzVal;
}
console.debug('Sending POST request to ', newRequest.url, ' with options: ', newRequest.options);
console.debug(`Sending POST request to ${newRequest.url} with options ${newRequest.options}`);
return got.post(newRequest.url, newRequest.options);
}

function convoApiFactory(url, pageId, authSettings, dependencies) {
const got = dependencies.got.extend({
headers: {
'content-type': 'application/json'
},
hooks: {
// Next hook will generate and add a valid signature to every
beforeRequest: [
(options) => {
if (authSettings.authType === 'Signature' && options.body) {
const signature = generateSignature(options.body, authSettings.secret);
const updatedOptions = {
headers: {
...options.headers,
'X-Hub-Signature': signature
}
};
// eslint-disable-next-line no-param-reassign
options.headers = updatedOptions.headers;
console.log('Updated request headers to ', options.headers);
}
}
]
}
});

return {
sendText: (senderId, inBody) => {
const body = buildBody(senderId, pageId, inBody);
console.log(`sending text message to Convo: ${JSON.stringify({
console.log(`sending text message to Convo ${JSON.stringify({
body, url
})}`);
return sendPostRequest(got, url, authSettings, body);
Expand Down Expand Up @@ -143,7 +121,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
watermark: Date.now(),

}, 'delivery');
console.log(`sending Delivery: ${JSON.stringify({ body, url })}`);
console.log(`sending Delivery ${JSON.stringify({ body, url })}`);
return sendPostRequest(got, url, authSettings, body);
},
sendDeliveryFailure: () => {
Expand Down
6 changes: 0 additions & 6 deletions src/helpers/generateSignature.js

This file was deleted.

2 changes: 1 addition & 1 deletion src/skeletonRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const express = require('express');
const convoApiFactory = require('./helpers/convoApiFactory');
const authSettings = require('../auth-settings');

const url = process.env.CONVO_WEBHOOK_URL;
const url = `${process.env.CONVO_API_GATEWAY}/cg/mc/custom/${process.env.CHANNEL_GUID}`;

const router = express.Router();

Expand Down

0 comments on commit 12698a6

Please sign in to comment.