-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
WIP: basic working structure & start of tests
- Loading branch information
Showing
6 changed files
with
303 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |