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 6 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
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
63 changes: 63 additions & 0 deletions lib/authHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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) {
Copy link
Member

Choose a reason for hiding this comment

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

nit, but tokenAttributes will always be truthy so it isn't doing anything in this conditional. You could use tokenAttributes.length, but ultimately it won't matter much so I'll leave it to you?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call; though the result is the same without, I added .length because I felt checking both here is a tiny bit more descriptive.

// 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 normalizePart = (part) => capitalize(part);
const normalField = key.split('-').map(normalizePart).join('-');
Copy link
Member

Choose a reason for hiding this comment

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

nit, but normalizePart is identical to capitalize, you could just map(capitalize) and have the same behavior I believe

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
};
88 changes: 65 additions & 23 deletions lib/ui/public/app/auth/Auth.vue
Original file line number Diff line number Diff line change
@@ -1,49 +1,91 @@
<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="(credential.type === 'token' && !credential.token) || (credential.type === 'user' && (!credential.username || !credential.password))" />
Copy link
Member

Choose a reason for hiding this comment

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

a computed property for the :disableNext prop might be a little easier to read, but certainly not a blocker

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call. And I went ahead & made that property function a little more verbose; feedback welcome on that.

Copy link
Member

Choose a reason for hiding this comment

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

I like it! The more verbose version is a bit easier to reason about I think 👍🏻

</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,
};
},
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);
}
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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
"bson-objectid": "^2.0.1",
"express": "^4.17.1",
"flat": "^5.0.2",
"leadconduit-custom": "^2.16.0",
"leadconduit-custom": "^2.23.4",
"leadconduit-integration-ui": "^1.1.4",
"leadconduit-sanitize-name": "^1.0.2",
"lodash": "^4.17.21",
"request": "^2.88.2",
"vue": "^2.6.12",
"vue-router": "^3.5.1",
"vuex": "^3.6.2"
Expand Down
Loading
Loading