-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 6 commits
cd0228f
c2c9179
623651b
d935c3d
1969413
96a70c1
ddb3d4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit, but There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call; though the result is the same without, I added |
||
// 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('-'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit, but |
||
normalHeaders[normalField] = headers[key]; | ||
}); | ||
return normalHeaders; | ||
}; | ||
|
||
module.exports = { | ||
convertAuthConfig, | ||
getTokenAttributes, | ||
normalizeHeaders, | ||
substituteHeaderTokens | ||
}; |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
// 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 | ||
}; |
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 & 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))" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a computed property for the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
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"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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!