Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth2 support #1

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +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_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
54 changes: 39 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +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>`
* `export SHARED_SECRET=<32_CHARACTER_STRING>`
* `export ACCESS_TOKEN=<STRING that is configured in the channel>`
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.

### Docker
### 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

On the Docker host system, run the following sequence of commands:
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!

### Docker

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 @@ -49,12 +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. *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 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 @@ -82,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
27 changes: 27 additions & 0 deletions auth-settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module.exports = {
// OAuth config for inbound
oauthConfig: {
tokenUrl: `${process.env.CONVO_API_GATEWAY}/oauth/token`,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET
},

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

// 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 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: {
'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;
30 changes: 24 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,32 @@ 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');
let valid = false;
if (authz && authz.startsWith('Bearer')) {
const token = authz.split(' ')[1];
if (authSettings.authTypeOutbound === 'OAuth') {
valid = isTokenValid(token);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (...) { add spaces for style compliance.

} 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
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;
53 changes: 53 additions & 0 deletions src/helpers/OAuthClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const got = require('got');
const qs = require('querystring');
const cache = require('./cache');
const { defaultOAuthExpiresSecs } = require('../../auth-settings');

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'};
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 ${oauthTokenRequestCredentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
});
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, expiresIn * 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
};
38 changes: 38 additions & 0 deletions src/helpers/TestOAuthServer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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 { oauthServer, defaultOAuthExpiresSecs } = require('../../auth-settings');
const cache = require('./cache');

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: defaultOAuthExpiresSecs });
}
});

function isTokenValid(token) {
return cache.get(token) || false;
}

module.exports = {
router,
isTokenValid
};
Loading