Skip to content

Commit

Permalink
OAuth2 support
Browse files Browse the repository at this point in the history
  • Loading branch information
edevalais-medallia committed Aug 30, 2021
1 parent 86aa76a commit 8a4754d
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 36 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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>

RUN env && npm ci --loglevel warn
Expand Down
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# 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 @@ -21,14 +20,38 @@ sequence of commands:

1. `git clone <where ever we host the skeleton>`
2. Set environment variable:
* `export CONVO_WEBHOOK_URL=<MEDALLIA_CONVERSATION_HOST>`
* `export SHARED_SECRET=<32_CHARACTER_STRING>`
* `export ACCESS_TOKEN=<STRING that is configured in the channel>`
* `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>`
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
* 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:
Expand All @@ -49,12 +72,7 @@ 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. *Secret* The string must be 32 characters long. This is used to generate
signature of the body to send it to Medallia Conversations
* This example is the `SHARED_SECRET` configured in Conversations
`<32_CHARACTER_STRING>` above.
5. *Token* A string to verify send message. You *must* configure
accordingly and provide as env variable ACCESS_TOKEN.
4. Configure inbound and outbound auth settings following the recommendations from "Auth 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
30 changes: 30 additions & 0 deletions auth-settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module.exports = {

// authTypeInbound can be 'OAuth2' or 'Signature'
authTypeInbound: process.env.AUTH_TYPE_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`,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
},
//for authTypeInbound Signature
secret: process.env.SHARED_SECRET,

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

// for outbound request API-Token verification (i.e. requests coming from MC)
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
oauthServer: {
tokenPath: '/token',
clients: {
'ConversationsClient': 'S3cr3t123!'
}
}
};
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@
"body-parser": "^1.15.0",
"compression": "^1.7.3",
"express": "^4.16.4",
"express-basic-auth": "^1.2.0",
"got": "^9.6.0",
"helmet": "^3.16.0",
"hpp": "^0.2.2",
"lodash": "^4.17.11"
"lodash": "^4.17.11",
"ttl": "^1.3.1"
},
"devDependencies": {
"eslint": "^5.16.0",
"eslint-config-airbnb-base": "^13.1.0",
"eslint-plugin-jest": "^23.20.0",
"eslint-plugin-import": "^2.16.0"
"eslint-plugin-import": "^2.16.0",
"eslint-plugin-jest": "^23.20.0"
}
}
2 changes: 2 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const hpp = require('hpp');
const helmet = require('helmet');
const skeletonRouter = require('./skeletonRouter');
const convoRouter = require('./convoRouter');
const oauthServer = require('./helpers/TestOAuthServer');

const app = express();

Expand All @@ -27,5 +28,6 @@ app.get('/state', (req, res) => {

app.use('/', skeletonRouter);
app.use('/custom', convoRouter);
app.use(oauthServer.router);

module.exports = app;
31 changes: 25 additions & 6 deletions src/convoRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ const _ = require('lodash');
const express = require('express');

const router = express.Router();

const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
const { isTokenValid } = require('./helpers/TestOAuthServer');
const authSettings = require('../auth-settings');

router.post('/me/messages', (req, res) => {
console.log(`/me/messages called with: ${JSON.stringify({
Expand All @@ -12,14 +12,33 @@ router.post('/me/messages', (req, res) => {
req_query: req.query,
})}`);

if (_.isEqual(req.query.access_token, ACCESS_TOKEN)) {
res.json({
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'){
valid = isTokenValid(token);
}else if(authSettings.authTypeOutbound === 'API-Token'){
valid = _.isEqual(token, authSettings.accessToken);
}
console.log('Successfully verified Bearer access token');
} else if (authSettings.authTypeOutbound === 'API-Token' && _.isEqual(req.query.access_token, authSettings.accessToken)) {
valid = true;
console.log('Successfully verified API Token');
}

if (valid) {
const response = {
recipient_id: req.body.recipient.id, // Messaging service consumer id
message_id: `${req.body.recipient.id}.${new Date().getTime()}` // Messaging service individual message id
});
};
console.log(`Returning response for /me/messages call: ${JSON.stringify(response)}`);
res.json(response);
} else {
console.warn('Invalid token in request to /me/messages');
res.status(401).json({
status: 'Invalid access token'
status: 'Invalid token'
});
}
});
Expand Down
54 changes: 54 additions & 0 deletions src/helpers/OAuthClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const got = require('got');
const Cache = require('ttl');
const qs = require('querystring');

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}`);
});
cache.on('miss', (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}`);
});
}

async function getAccessToken(authSettings) {
let token = null;
if (authSettings.oauthConfig) {
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);
try {
const { body } = await got.post(tokenUrl, {
body: qs.encode(payload),
responseType: 'json',
headers: {
Authorization: `Basic ${cred}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
});
console.log('Received /token response from MC: ', body);
const res = JSON.parse(body);
token = res.access_token;
cache.put(clientId, token, res.expires_in * 1000);
} catch (e) {
console.error(`Error fetching access token from ${tokenUrl}`, e);
}
} else {
console.log(`Returning cached token: ${token} for client: ${clientId}`);
}
}
return token;
}

module.exports = {
getAccessToken
};
46 changes: 46 additions & 0 deletions src/helpers/TestOAuthServer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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

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 staticAuth = basicAuth({
users: oauthServer.clients
});

router.post(oauthServer.tokenPath, staticAuth, (req, res) => {
const grantType = req.body.grant_type;
if (!grantType || grantType !== 'client_credentials') {
res.status(400).send({ error: 'invalid_grant' });
} else {
const token = crypto.randomBytes(16).toString('hex');
const { auth } = req;
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 });
}
});

// 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;
}

module.exports = {
router,
isTokenValid
};
37 changes: 28 additions & 9 deletions src/helpers/convoApiFactory.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const _ = require('lodash');
const oauthClient = require('./OAuthClient');
const generateSignature = require('../helpers/generateSignature');

function buildBody(senderId, pageId, inBody, type = 'message') {
Expand Down Expand Up @@ -44,14 +45,31 @@ function buildBody(senderId, pageId, inBody, type = 'message') {
return body;
}

function sendPostRequest(got, url, body) {
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}`;
}
return authzVal;
}

async function sendPostRequest(got, url, authSettings, body) {
const headers = {};
const newRequest = {
url,
options: {
body: JSON.stringify(body)
body: JSON.stringify(body),
headers
}
};

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);
return got.post(newRequest.url, newRequest.options);
}

Expand All @@ -64,7 +82,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
// Next hook will generate and add a valid signature to every
beforeRequest: [
(options) => {
if (options.body) {
if (authSettings.authType === 'Signature' && options.body) {
const signature = generateSignature(options.body, authSettings.secret);
const updatedOptions = {
headers: {
Expand All @@ -74,6 +92,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
};
// eslint-disable-next-line no-param-reassign
options.headers = updatedOptions.headers;
console.log('Updated request headers to ', options.headers);
}
}
]
Expand All @@ -86,7 +105,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
console.log(`sending text message to Convo: ${JSON.stringify({
body, url
})}`);
return sendPostRequest(got, url, body);
return sendPostRequest(got, url, authSettings, body);
},
sendImage: (senderId, imageUrl) => {
const body = buildBody(senderId, pageId, {
Expand All @@ -100,7 +119,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
]
});
console.log(`sending image message to Convo ${JSON.stringify({ body, url })}`);
return sendPostRequest(got, url, body);
return sendPostRequest(got, url, authSettings, body);
},
sendMedia: (senderId, mediaUrl, mediaType) => {
const body = buildBody(senderId, pageId, {
Expand All @@ -114,7 +133,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
]
});
console.log(`sending ${mediaType} message to Convo ${JSON.stringify({ body, url })}`);
return sendPostRequest(got, url, body);
return sendPostRequest(got, url, authSettings, body);
},
sendDelivery: (senderId, mid) => {
const body = buildBody(senderId, pageId, {
Expand All @@ -125,7 +144,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {

}, 'delivery');
console.log(`sending Delivery: ${JSON.stringify({ body, url })}`);
return sendPostRequest(got, url, body);
return sendPostRequest(got, url, authSettings, body);
},
sendDeliveryFailure: () => {
console.log('sending Delivery failure not implemented in conversations side.');
Expand All @@ -139,7 +158,7 @@ function convoApiFactory(url, pageId, authSettings, dependencies) {
}, 'read'
);
console.log(`sending Read ${JSON.stringify({ body, url })}`);
return sendPostRequest(got, url, body);
return sendPostRequest(got, url, authSettings, body);
}
};
}
Expand Down
Loading

0 comments on commit 8a4754d

Please sign in to comment.