From cd0228f22b59ccdedd461e90d16a771262f5a622 Mon Sep 17 00:00:00 2001 From: Chris Grayson Date: Fri, 8 Mar 2024 16:46:32 -0600 Subject: [PATCH] WIP: basic working structure & start of tests --- index.js | 1 + lib/authHelpers.js | 63 +++++++++++++++++++++ lib/authjson.js | 117 +++++++++++++++++++++++++++++++++++++++ package.json | 6 +- test/authHelpers-spec.js | 74 +++++++++++++++++++++++++ test/authjson-spec.js | 44 +++++++++++++++ 6 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 lib/authHelpers.js create mode 100644 lib/authjson.js create mode 100644 test/authHelpers-spec.js create mode 100644 test/authjson-spec.js diff --git a/index.js b/index.js index e1f4684..9bbe00b 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ module.exports = { outbound: { + authenticated_json: require('./lib/authjson'), json: require('./lib/json') }, ui: require('./lib/ui') diff --git a/lib/authHelpers.js b/lib/authHelpers.js new file mode 100644 index 0000000..afe3eb0 --- /dev/null +++ b/lib/authHelpers.js @@ -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 +}; diff --git a/lib/authjson.js b/lib/authjson.js new file mode 100644 index 0000000..3b3b58c --- /dev/null +++ b/lib/authjson.js @@ -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 +}; diff --git a/package.json b/package.json index ccc02a7..295b493 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "leadconduit-json", - "version": "1.1.0", + "version": "1.2.0", "description": "LeadConduit JSON integration", "main": "index.js", "scripts": { @@ -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" diff --git a/test/authHelpers-spec.js b/test/authHelpers-spec.js new file mode 100644 index 0000000..bf47497 --- /dev/null +++ b/test/authHelpers-spec.js @@ -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); + }); +}); diff --git a/test/authjson-spec.js b/test/authjson-spec.js new file mode 100644 index 0000000..b3e171a --- /dev/null +++ b/test/authjson-spec.js @@ -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'); + }); + }); +});