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

Sc 68589/new integration bearer authentication and #13

Merged
merged 7 commits into from
Mar 26, 2024
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org)

## [1.2.0] - 2024-03-25
### Added
- added Token Authenticated integration ([sc-68589](https://app.shortcut.com/active-prospect/story/68589/new-integration-bearer-authentication-and-authorization-method))

## [1.1.0] - 2021-06-23
### Added
- added an RUI to assist with configuration
Expand Down
7 changes: 7 additions & 0 deletions docs/outbound.token-authenticated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: Token-Authenticated JSON
tag: Code
integration_type: delivery
link: https://activeprospect.com/products/leadconduit/
---
Like the "plain" JSON integration, but allows configuration of an authentication token. That in turn is used to get and refresh access tokens during delivery as needed.
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = {
outbound: {
token_authenticated: require('./lib/authjson'),
Copy link
Member

Choose a reason for hiding this comment

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

I'll be honest, I don't know the point of the /docs directory, but should we add an entry there too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These markdown files are the source of the descriptions in-app and on the "catalog" pages. Absolutely need one here, thanks!

json: require('./lib/json')
},
ui: require('./lib/ui')
Expand Down
62 changes: 62 additions & 0 deletions lib/authHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const { capitalize } = require('lodash');

const ignoreCredentialAttributes = [
"_id",
"package",
"type",
"name",
"version",
"account_id",
"created_at",
"updated_at"
];

// return all non-metadata attributes (i.e., those that could have real data) from the given credential object
const getTokenAttributes = (credential) => {
return Object.keys(credential).filter(key => !ignoreCredentialAttributes.includes(key));
};

// replace auth token names with their credential values in the given vars.headers object
// e.g., headers: {"Authorization": "Bearer token"} -> {"Authorization": "Bearer abc123"}
const substituteHeaderTokens = (headers, credential) => {
const tokenAttributes = getTokenAttributes(credential);

if (headers && tokenAttributes.length) {
// iterate over each header value (e.g., 'Bearer TOKEN')
Object.keys(headers).forEach(property => {
// looking for each tokenAttribute (e.g., 'token')
tokenAttributes.forEach(tokenAttribute => {
const re = new RegExp(`\\b${tokenAttribute}\\b`, "i"); // \b -> word boundary check
headers[property] = headers[property].replace(re, credential[tokenAttribute]);
});
});
}
return headers;
};

// shift the authentication-specific mappings to the standard JSON integration ones, excluding all others
const convertAuthConfig = (vars) => {
return {
url: vars.authentication_url,
method: vars.authentication_method || 'POST',
header: vars.authentication_header,
json_property: vars.authentication_property,
};
};

const normalizeHeaders = (headers) => {
const normalHeaders = {};

Object.keys(headers).forEach(key => {
const normalField = key.split('-').map(capitalize).join('-');
normalHeaders[normalField] = headers[key];
});
return normalHeaders;
};

module.exports = {
convertAuthConfig,
getTokenAttributes,
normalizeHeaders,
substituteHeaderTokens
};
117 changes: 117 additions & 0 deletions lib/authjson.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const { get } = require("lodash");
const request = require('request');
const json = require('leadconduit-custom').outbound.json;
const { substituteHeaderTokens, convertAuthConfig, normalizeHeaders} = require('./authHelpers');

const validate = (vars) => {
const baseValidation = json.validate(vars);
if (baseValidation) {
return baseValidation;
}
if (!vars.credential_id || vars.credential?.type !== 'token') return 'ID of token credential is required';
if (!vars.authentication_url) return 'authentication URL is required';
};

const refreshToken = (vars, callback) => {
vars.authentication_header = substituteHeaderTokens(vars.authentication_header, vars.credential);
const opts = json.request(convertAuthConfig(vars));

request(opts, (err, response, body) => {
if (err) return callback(`Error while fetching new token: ${err}`, null);

let tokenResponse = {};
try {
tokenResponse = JSON.parse(body);
}
catch (err) {
return callback(`Error while parsing refresh token: ${err}`);
}

return callback(null, tokenResponse);
});
};

const parseResponse = (vars, requestOpts, response, body) => {
const result = {
status: response.statusCode,
version: response.httpVersion || '1.1',
headers: normalizeHeaders(response.headers),
body
};

let event;
try {
event = json.response(vars, requestOpts, result);
} catch (error) {
event = {
outcome: 'error',
reason: `Error parsing response: ${error}`
};
}
return event;
};

const handle = (vars, callback, retried = false) => {
// save an un-substituted copy of the headers object in case it's needed for retry
const originalVarsHeader = Object.assign({}, vars.header);

vars.header = substituteHeaderTokens(vars.header, vars.credential);
const requestOpts = json.request(vars);

// make the request with the current access token
request(requestOpts, (err, response, body) => {
if (err) {
return callback(null, { outcome: 'error', reason: err.message || `Unknown Error: ${response.statusCode}`});
}

// check for Unauthorized or Forbidden response
if (response.statusCode === 401 || response.statusCode === 403) {
// refresh token and try again
if (retried) {
return callback(null, { outcome: 'error', reason: 'Unable to authenticate after attempting to refresh token' });
}
else {
refreshToken(vars, (err, tokenResponse) => {
if(err) {
return callback(null, { outcome: 'error', reason: err });
}

// update vars.credential with the new token, using the user-configured (or default) attribute
const tokenPath = vars.authentication_token_path || 'accessToken';
const tokenName = tokenPath.split('.').at(-1); // use the name after the final '.', if there are any
vars.credential[tokenName] = get(tokenResponse, tokenPath);
Comment on lines +79 to +82
Copy link
Member

Choose a reason for hiding this comment

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

I know this is a stupid question but I've been meaning to ask and I keep forgetting.

Are credentials saved back to the DB after integrations run? I've been wondering how the token gets persisted between integration invocations

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not a stupid question at all; saving updated credentials is one of the many things send does. See https://github.com/activeprospect/leadconduit-api/blob/59c0a7a3dd966e99bf8dd9c1d20a08283c42bf6e/lib/handler/util/send.js#L291-L316 (& ff.)


// retry
vars.header = originalVarsHeader;
return handle(vars, callback, true);
});
}
} else {
const event = parseResponse(vars, requestOpts, response, body);
return callback(null, event);
}
});
};

const requestVariables = () => {
const baseVariables = json.request.variables();

// make credential_id required (probably at [0] but let's not assume)
const credentialId = baseVariables.find(variable => variable.name === 'credential_id');
credentialId.required = true;

return [
{ name: 'authentication_url', type: 'string', description: 'URL to get access token from', required: true },
{ name: 'authentication_method', type: 'string', description: 'HTTP method (GET, or POST) for authentication request (default: POST)', required: false },
{ name: 'authentication_property.*',type: 'wildcard', description: 'JSON property in dot notation for authentication request', required: false },
{ name: 'authentication_header.*', description: 'HTTP header to send in the authentication request. Include the name of a credential field (TOKEN, ACCESS_TOKEN) and it will be replaced by that value', type: 'wildcard', required: false },
{ name: 'authentication_token_path', description: 'The JSON dot-notation path used to find the access token. The final element will become the name of the credential field (default: accessToken)', type: 'string', required: false },
].concat(baseVariables);
};

module.exports = {
handle,
requestVariables,
responseVariables: json.response.variables,
validate
};
104 changes: 81 additions & 23 deletions lib/ui/public/app/auth/Auth.vue
Original file line number Diff line number Diff line change
@@ -1,49 +1,107 @@
<template>
<div>
<header>
Basic Authentication
</header>
<header>Authentication</header>
<section>
<p>A username and password combo for standard HTTP authentication.
<form>
<p v-if="!originalType">What type of authentication should be used?</p>
<ul>
<li>
<label>Username</label>
<input type="text" v-model="credential.username">
<li v-if="!originalType">
<label>
<input type="radio" v-model="credential.type" value="none" id="none"> <b>None</b> - The server doesn't require any authentication
</label>
</li>
<li>
<label>Password</label>
<input type="text" v-model="credential.password">
<li v-if="originalType === 'user' || !originalType">
<label>
<input type="radio" v-model="credential.type" value="user" id="user"> <b>Username &amp; Password</b> - The server requires a username and password ("Basic" authentication)
</label>
<section v-if="credential.type === 'user'" style="margin-top: 2px;">
<ul>
<li style="margin-bottom: 2px;">
<label>Username</label>
<input type="text" v-model="credential.username">
</li>
<li style="margin-bottom: 2px;">
<label>Password</label>
<input type="text" v-model="credential.password">
</li>
</ul>
</section>
</li>
<li v-if="originalType === 'token' || !originalType">
<label>
<input type="radio" v-model="credential.type" value="token" id="token"> <b>Bearer Token</b> - The server uses token-based authentication
</label>
<section v-if="credential.type === 'token'" style="margin-top: 2px;">
<ul>
<li style="margin-bottom: 2px;">
<label>Token</label>
<input type="text" v-model="credential.token">
</li>
</ul>
</section>
</li>
</ul>
</form>
</section>
<footer>
<button v-on:click="$store.dispatch('cancel')">Cancel</button>
<button v-on:click="next" class="primary">{{ (credential.username && credential.password) ? 'Continue' : 'Skip' }}</button>
</footer>
<Navigation :onNext="next" :disableNext="disableNextButton" />
</div>
</template>

<script>
import { Navigation } from '@activeprospect/integration-components';
export default {
data() {
// this isn't a "real" credential, which wouldn't have `type: none` or both password & token
const credentialTemplate = {
username: '',
password: '',
token: '',
package: 'leadconduit-json',
type: 'none'
};
const existingCredential = this.$store.getters.getCredential;
return {
credential: this.$store.getters.getCredential || {
username: '',
password: '',
package: 'leadconduit-json',
type: 'user'
}
originalType: existingCredential?.type, // used to keep user from changing type when editing
credential: existingCredential || credentialTemplate,
};
},
computed: {
disableNextButton() {
if(this.credential.type === 'token') {
return !this.credential.token;
}
else if(this.credential.type === 'user') {
return !this.credential.username || !this.credential.password;
}
else {
return false;
}
}
},
methods: {
next() {
if (this.credential.username && this.credential.password) {
return this.$store.dispatch('createCredential', this.credential);
if (this.credential.type === 'user') {
delete this.credential.token;
this.$store.dispatch('saveCredential', this.credential);
} else if (this.credential.type === 'token') {
delete this.credential.username;
delete this.credential.password;
this.$store.dispatch('saveCredential', this.credential);
} else {
// must be 'none'
this.$store.state.ui.create({'redirect': 'config'});
}
this.$store.state.ui.create({ 'redirect': 'config'});
},
},
components: { Navigation }
};
</script>

<style>
label {
font-weight: normal !important;
}
input[type=text] {
width: 50%
}
</style>
10 changes: 5 additions & 5 deletions lib/ui/public/app/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const initStore = (config, ui) => new Vuex.Store({
Vue.set(state, 'parsedFields', fields);
},
setJson (state, json) {
Vue.set(state, 'rawJson', json)
Vue.set(state, 'rawJson', json);
},
setResponseOpts (state, { outcomePath, outcomeTerm, reasonPath }) {
Vue.set(state, 'outcomePath', outcomePath);
Expand All @@ -33,15 +33,15 @@ const initStore = (config, ui) => new Vuex.Store({
}
},
getters: {
getCredential: (state, getters) => {
getCredential: () => {
return config.credential;
}
},
actions: {
cancel (context) {
context.state.ui.cancel();
},
createCredential(context, credential) {
saveCredential(context, credential) {
if (!credential.id) credential.id = new ObjectID().toHexString();
context.state.ui.create({ credential });
},
Expand All @@ -58,7 +58,7 @@ const initStore = (config, ui) => new Vuex.Store({
id: config.entity.id
},
integration: {
module_id: config.integration,
module_id: config.credential?.type === 'token' ? 'leadconduit-json.outbound.token_authenticated' : 'leadconduit-json.outbound.json',
mappings: [{
property: 'credential_id',
value: (config.credential) ? config.credential.id : undefined
Expand All @@ -74,7 +74,7 @@ const initStore = (config, ui) => new Vuex.Store({
}, {
property: 'reason_path',
value: context.state.reasonPath
}].filter((mapping) => { return mapping.value })
}].filter((mapping) => { return mapping.value; })
}
}]
};
Expand Down
1 change: 0 additions & 1 deletion lib/ui/public/index.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<!DOCTYPE html>
<head>
<title>Leadconduit Integration</title>
<link href="/lc-client.css" rel="stylesheet">
Copy link
Contributor Author

Choose a reason for hiding this comment

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

(Not a big deal but this should never have been checked in this way.)

</head>
<body class="rich-integration-ui">
<div id="app"></div>
Expand Down
Loading
Loading