From 8a4754dee33c263790a0e684443bca296fe1c607 Mon Sep 17 00:00:00 2001 From: Ezequiel de Valais Date: Mon, 30 Aug 2021 10:57:10 -0300 Subject: [PATCH 1/2] OAuth2 support --- Dockerfile | 1 + README.md | 38 +++++++++++++++++------- auth-settings.js | 30 +++++++++++++++++++ package.json | 8 +++-- src/app.js | 2 ++ src/convoRouter.js | 31 +++++++++++++++---- src/helpers/OAuthClient.js | 54 ++++++++++++++++++++++++++++++++++ src/helpers/TestOAuthServer.js | 46 +++++++++++++++++++++++++++++ src/helpers/convoApiFactory.js | 37 +++++++++++++++++------ src/skeletonRouter.js | 9 +----- 10 files changed, 220 insertions(+), 36 deletions(-) create mode 100644 auth-settings.js create mode 100644 src/helpers/OAuthClient.js create mode 100644 src/helpers/TestOAuthServer.js diff --git a/Dockerfile b/Dockerfile index 19d1d50..6060473 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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:// ENV CONVO_WEBHOOK_URL=https:///cg/mc/custom/ RUN env && npm ci --loglevel warn diff --git a/README.md b/README.md index 20e756b..514ccb9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -21,14 +20,38 @@ sequence of commands: 1. `git clone ` 2. Set environment variable: - * `export CONVO_WEBHOOK_URL=` - * `export SHARED_SECRET=<32_CHARACTER_STRING>` - * `export ACCESS_TOKEN=` + * `export CONVO_WEBHOOK_URL=/cg/mc/custom/` + 1. For conversations inbound messages + * `export AUTH_TYPE_INBOUND=` + * `export SHARED_SECRET=<32_CHARACTER_STRING only for Signature>` + * `export CONVO_INSTANCE_URL=/oauth/token` + * `export CLIENT_ID=` + * `export CLIENT_SECRET=` + 2. For conversations outbound messages + * `export AUTH_TYPE_OUTBOUND=` + * `export ACCESS_TOKEN=` 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: /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://: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: @@ -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 diff --git a/auth-settings.js b/auth-settings.js new file mode 100644 index 0000000..6d7d319 --- /dev/null +++ b/auth-settings.js @@ -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!' + } + } +}; diff --git a/package.json b/package.json index 97f0bdc..fdb6775 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/app.js b/src/app.js index a3b0649..b6d9fa9 100644 --- a/src/app.js +++ b/src/app.js @@ -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(); @@ -27,5 +28,6 @@ app.get('/state', (req, res) => { app.use('/', skeletonRouter); app.use('/custom', convoRouter); +app.use(oauthServer.router); module.exports = app; diff --git a/src/convoRouter.js b/src/convoRouter.js index 7118253..9d0f776 100644 --- a/src/convoRouter.js +++ b/src/convoRouter.js @@ -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({ @@ -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' }); } }); diff --git a/src/helpers/OAuthClient.js b/src/helpers/OAuthClient.js new file mode 100644 index 0000000..e5346dc --- /dev/null +++ b/src/helpers/OAuthClient.js @@ -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 +}; diff --git a/src/helpers/TestOAuthServer.js b/src/helpers/TestOAuthServer.js new file mode 100644 index 0000000..999dfe4 --- /dev/null +++ b/src/helpers/TestOAuthServer.js @@ -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 +}; diff --git a/src/helpers/convoApiFactory.js b/src/helpers/convoApiFactory.js index 152465f..9d93a35 100644 --- a/src/helpers/convoApiFactory.js +++ b/src/helpers/convoApiFactory.js @@ -1,4 +1,5 @@ const _ = require('lodash'); +const oauthClient = require('./OAuthClient'); const generateSignature = require('../helpers/generateSignature'); function buildBody(senderId, pageId, inBody, type = 'message') { @@ -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); } @@ -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: { @@ -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); } } ] @@ -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, { @@ -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, { @@ -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, { @@ -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.'); @@ -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); } }; } diff --git a/src/skeletonRouter.js b/src/skeletonRouter.js index a37a5bc..156e86a 100644 --- a/src/skeletonRouter.js +++ b/src/skeletonRouter.js @@ -1,6 +1,7 @@ const got = require('got'); const express = require('express'); const convoApiFactory = require('./helpers/convoApiFactory'); +const authSettings = require('../auth-settings'); const url = process.env.CONVO_WEBHOOK_URL; @@ -9,10 +10,6 @@ const router = express.Router(); router.post('/messages', (req, res) => { console.log(`Receiving post to skeleton webhook (/messages): ${JSON.stringify(req.body)}`); const body = req.body; - const secret = process.env.SHARED_SECRET; - const authSettings = { - secret - }; console.log(`Receiving post to skeleton webhook (/messages): ${JSON.stringify(authSettings)}`); const convoApi = convoApiFactory(url, body.page_id, authSettings, { got }); @@ -28,10 +25,6 @@ router.post('/messages', (req, res) => { router.post('/deliveries', (req, res) => { console.log(`Receiving post to skeleton webhook (/deliveries): ${JSON.stringify(req.body)}`); const body = req.body; - const secret = process.env.SHARED_SECRET; - const authSettings = { - secret - }; console.log(`Receiving post to skeleton webhook (/deliveries): ${JSON.stringify(authSettings)}`); const convoApi = convoApiFactory(url, body.page_id, authSettings, { got }); From 12698a64ef4c8dd78481872a1e244a59d5f5eb08 Mon Sep 17 00:00:00 2001 From: Ezequiel de Valais Date: Wed, 8 Sep 2021 16:19:02 -0300 Subject: [PATCH 2/2] Add corrections --- Dockerfile | 10 +++-- README.md | 68 +++++++++++++++++--------------- auth-settings.js | 25 ++++++------ src/convoRouter.js | 5 +-- src/helpers/Cache.js | 8 ++++ src/helpers/OAuthClient.js | 27 ++++++------- src/helpers/TestOAuthServer.js | 26 +++++------- src/helpers/convoApiFactory.js | 38 ++++-------------- src/helpers/generateSignature.js | 6 --- src/skeletonRouter.js | 2 +- 10 files changed, 96 insertions(+), 119 deletions(-) create mode 100644 src/helpers/Cache.js delete mode 100644 src/helpers/generateSignature.js diff --git a/Dockerfile b/Dockerfile index 6060473..1192146 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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:// -ENV CONVO_WEBHOOK_URL=https:///cg/mc/custom/ +ENV CONVO_API_GATEWAY=https:// +ENV CHANNEL_GUID= +ENV CLIENT_ID= +ENV CLIENT_SECRET= +ENV AUTH_TYPE_OUTBOUND= +ENV ACCESS_TOKEN= +ENV DEFAULT_OAUTH_EXPIRES_SECS= RUN env && npm ci --loglevel warn diff --git a/README.md b/README.md index 514ccb9..15c744d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -19,46 +20,50 @@ On a host machine with hostname/IP of , run the following sequence of commands: 1. `git clone ` -2. Set environment variable: - * `export CONVO_WEBHOOK_URL=/cg/mc/custom/` - 1. For conversations inbound messages - * `export AUTH_TYPE_INBOUND=` - * `export SHARED_SECRET=<32_CHARACTER_STRING only for Signature>` - * `export CONVO_INSTANCE_URL=/oauth/token` - * `export CLIENT_ID=` - * `export CLIENT_SECRET=` - 2. For conversations outbound messages - * `export AUTH_TYPE_OUTBOUND=` - * `export ACCESS_TOKEN=` +2. Set environment variable + * `export CONVO_API_GATEWAY=https://` + * `export CHANNEL_GUID=` + * `export DEFAULT_OAUTH_EXPIRES_SECS=` + 1. For communicating from the channel adapter to Medallia Conversations, + * `export CLIENT_ID=` + * `export CLIENT_SECRET=` + 2. For communicating from Medallia Conversations to the channel adapter, + * `export AUTH_TYPE_OUTBOUND=` + * `export ACCESS_TOKEN=` 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: /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://: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://: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 `` to your Medallia Conversations host. + * Change `` to your Medallia Conversations host. + * Change `` to your Medallia Conversations channel GUID. + * Change `` to your Medallia Conversations channel client ID. + * Change `` to your Medallia Conversations channel secret. + * Change `` to 'API-Token' or 'OAuth'. + * Change `` to your token if you are using API-Token. + * Change `` to the default OAuth Expire time in secs. 2. `docker build -t skeleton .` 3. `docker run -p 1338:1338 -t skeleton` @@ -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 @@ -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:///cg/mc/custom/"} +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:///cg/mc/custom/"} response from sendText: OK /me/messages called with: {"body":{"recipient":{"id":"1234user5678"},"message":{"text":"Hello World!"},"notification_type":"REGULAR"},"access_token":""} diff --git a/auth-settings.js b/auth-settings.js index 6d7d319..dda4a0b 100644 --- a/auth-settings.js +++ b/auth-settings.js @@ -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: { diff --git a/src/convoRouter.js b/src/convoRouter.js index 9d0f776..853d683 100644 --- a/src/convoRouter.js +++ b/src/convoRouter.js @@ -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'); diff --git a/src/helpers/Cache.js b/src/helpers/Cache.js new file mode 100644 index 0000000..3cfe46d --- /dev/null +++ b/src/helpers/Cache.js @@ -0,0 +1,8 @@ +const Cache = require('ttl'); +const { defaultOAuthExpiresSecs } = require('../../auth-settings'); + +const cache = new Cache({ + ttl: defaultOAuthExpiresSecs * 1000 +}); + +module.exports = cache; \ No newline at end of file diff --git a/src/helpers/OAuthClient.js b/src/helpers/OAuthClient.js index e5346dc..2a56228 100644 --- a/src/helpers/OAuthClient.js +++ b/src/helpers/OAuthClient.js @@ -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}`); }); } @@ -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; diff --git a/src/helpers/TestOAuthServer.js b/src/helpers/TestOAuthServer.js index 999dfe4..cb0a0ca 100644 --- a/src/helpers/TestOAuthServer.js +++ b/src/helpers/TestOAuthServer.js @@ -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 @@ -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; } diff --git a/src/helpers/convoApiFactory.js b/src/helpers/convoApiFactory.js index 9d93a35..80f5439 100644 --- a/src/helpers/convoApiFactory.js +++ b/src/helpers/convoApiFactory.js @@ -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 })}`); @@ -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; } @@ -65,11 +63,10 @@ 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); } @@ -77,32 +74,13 @@ 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); @@ -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: () => { diff --git a/src/helpers/generateSignature.js b/src/helpers/generateSignature.js deleted file mode 100644 index 95972cc..0000000 --- a/src/helpers/generateSignature.js +++ /dev/null @@ -1,6 +0,0 @@ -const crypto = require('crypto'); - -module.exports = function generateSignature(body, secret) { - const signature = crypto.createHmac('sha1', secret).update(body).digest('hex'); - return `sha1=${signature}`; -}; diff --git a/src/skeletonRouter.js b/src/skeletonRouter.js index 156e86a..5f31a64 100644 --- a/src/skeletonRouter.js +++ b/src/skeletonRouter.js @@ -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();