Skip to content

Commit

Permalink
WIP: basic working structure & start of tests
Browse files Browse the repository at this point in the history
  • Loading branch information
cgrayson committed Mar 8, 2024
1 parent 440b7fa commit cd0228f
Show file tree
Hide file tree
Showing 6 changed files with 303 additions and 2 deletions.
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: {
authenticated_json: require('./lib/authjson'),
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) {
// 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('-');
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) return 'credential ID 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 tokens: ${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);

// 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
};
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "leadconduit-json",
"version": "1.1.0",
"version": "1.2.0",
"description": "LeadConduit JSON integration",
"main": "index.js",
"scripts": {
Expand All @@ -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
74 changes: 74 additions & 0 deletions test/authHelpers-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const {assert} = require('chai');
const { convertAuthConfig, getTokenAttributes, substituteHeaderTokens } = require('../lib/authHelpers');

describe('Auth Helpers', function () {

const credential = {
"_id": "65e104ad228e27e6f9599999",
"package": "leadconduit.custom",
"type": "token",
"name": "My First Token",
"version": "14.12.13",
"created_at": "2024-02-29T22:26:53.506Z",
"updated_at": "2024-02-29T22:26:53.506Z",
"token": "t0k3n",
"accessToken": "foo",
"aSeeminglyRandomAttribute": "bar",
};

it('gets tokens', function () {
assert.deepEqual(getTokenAttributes(credential), ['token', 'accessToken', 'aSeeminglyRandomAttribute']);
});

it('substitute header tokens', function () {
const headers = {
Authorization: "Bearer TOKEN",
"X-Auth-Test-0": "Bearer token",
"X-Auth-Test-1": "Bearer tokenizable", // no replacement; "token" isn't on a word boundary
"X-Auth-Test-2": "Bearer version", // no replacement; "version" is an ignored metadata attribute
"X-Auth-Test-3": "What about aSeeminglyRandomAttribute"
};

const actual = substituteHeaderTokens(headers, credential);
assert.equal(actual["Authorization"], "Bearer t0k3n");
assert.equal(actual["X-Auth-Test-0"], "Bearer t0k3n");
assert.equal(actual["X-Auth-Test-1"], "Bearer tokenizable");
assert.equal(actual["X-Auth-Test-2"], "Bearer version");
assert.equal(actual["X-Auth-Test-3"], "What about bar");
});

it('converts auth config', function () {
const vars = {
authentication_url: 'https://auth.com',
authentication_method: 'GET',
authentication_header: {
'Authorization': 'Bearer than-thou'
},
authentication_property: {
'scopes[0]': 'contacts:read',
'scopes[1]': 'contacts:manage'
},
// the values below here should be overwritten by the conversion
url: 'https://endpoint.com',
method: 'POST',
header: {
'Accept': 'application/xml'
},
json_property: {
foo: 42
},
json_parameter: 'param',
extra_parameter: {
bar: 55
}
};

const actual = convertAuthConfig(vars);
assert.equal(actual.url, 'https://auth.com');
assert.equal(actual.method, 'GET');
assert.deepEqual(actual.header, { 'Authorization': 'Bearer than-thou' });
assert.deepEqual(actual.json_property, { 'scopes[0]': 'contacts:read', 'scopes[1]': 'contacts:manage' });
assert.isUndefined(actual.json_parameter);
assert.isUndefined(actual.extra_parameter);
});
});
44 changes: 44 additions & 0 deletions test/authjson-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const { assert } = require('chai');
const { requestVariables, validate } = require('../lib/authjson');

describe('Authenticated JSON', function () {

describe('Request variables', function () {
it('makes credential_id required', function () {
const variables = requestVariables();
const credentials = variables.filter(variable => variable.name === 'credential_id');
assert.equal(credentials.length, 1);
assert.isTrue(credentials[0].required);
});
});

describe('Validate', function () {
let vars;
beforeEach(function() {
vars = {
url: 'https://example.com/deliver',
credential_id: 'abc123',
authentication_url: 'https://example.com/authenticate'
};
});

it('requires base JSON vars', function () {
delete vars.url;
assert.equal(validate(vars), 'URL is required');
});

it('passes validation with all required vars', function () {
assert.isUndefined(validate(vars));
});

it('fails when missing credential ID', function () {
delete vars.credential_id;
assert.equal(validate(vars), 'credential ID is required');
});

it('fails when missing auth URL', function () {
delete vars.authentication_url;
assert.equal(validate(vars), 'authentication URL is required');
});
});
});

0 comments on commit cd0228f

Please sign in to comment.