ciscospark-webhook-validator
Version:
Use co-body and dataloader to validate incoming webhooks from Cisco Spark
143 lines (126 loc) • 4.85 kB
JavaScript
;
/* eslint-env es6, node */
/* eslint-disable max-classes-per-file */
const HTTP = require('http');
const HTTPS = require('https');
const OpenSSL = require('crypto');
const BodyParser = require('co-body');
const DataLoader = require('dataloader');
const _ = {}; // duplicates essential functionality:
_.get = (maybeObject, keyString, defaultValue) => {
return Object(maybeObject)[keyString] || defaultValue;
};
class SparkResponseError extends Error {
constructor(res) {
const body = SparkResponseError.getBody(res);
// eslint-disable-next-line no-magic-numbers
const statusCode = _.get(res, 'statusCode', 400);
const text = _.get(body, 'message', HTTP.STATUS_CODES[statusCode]);
super(`${text} (tracking ID: ${_.get(body, 'trackingid', 'none')})`);
Object.freeze(Object.defineProperty(this, 'response', { value: res }));
}
inspect() {
const body = SparkResponseError.getBody(this.response); // valid JSON
return `${SparkResponseError.name} ${JSON.stringify(body, null, '\t')}`;
}
static getBody(any) {
return _.get(any, 'body', {});
}
}
const Spark = {
RequestCache: WeakMap,
ResponseError: SparkResponseError,
getAPIEndpoint: () => {
return 'api.ciscospark.com';
},
getAccessToken: () => {
// eslint-disable-next-line no-process-env
return Promise.resolve(process.env.CISCOSPARK_ACCESS_TOKEN);
}
// will batch and cache REST API response(s):
};class SparkWebhookLoader extends DataLoader {
constructor(sparkAccessToken) {
const getWebhook = webhookID => new Promise((resolve, reject) => {
const options = {
headers: {
Accept: 'application/json', // required
Authorization: `Bearer ${sparkAccessToken}`
},
hostname: Spark.getAPIEndpoint(),
path: `/v1/webhooks/${webhookID}`
};
const req = HTTPS.get(options, res => {
const done = body => {
res.body = body; // for SparkResponseError
// eslint-disable-next-line no-magic-numbers
if (res.statusCode === 200) resolve(body);else reject(new SparkResponseError(res));
};
// consume the (incoming) res much like a req
BodyParser.json({ req: res }).then(done, reject);
});
req.once('error', reject);
});
super(webhookIDs => Promise.all(webhookIDs.map(getWebhook)));
}
}
// Once a token is known, it may be used to load a user's webhook details.
const loaders = new DataLoader(tokens => {
// Any token which is clearly invalid could/should throw here instead?
return Promise.all(tokens.map(token => new SparkWebhookLoader(token)));
});
Spark.getWebhookDetails = maybeWebhook => {
// could/should pass args to getAccessToken?
const createdBy = _.get(maybeWebhook, 'createdBy');
const id = _.get(maybeWebhook, 'id', maybeWebhook);
return Spark.getAccessToken(createdBy).then(token => loaders.load(token)).then(loader => loader.load(id));
};
const validateIncomingWebhook = (text, headers) => {
const header = _.get(headers, 'x-spark-signature'); // non-empty String, or:
if (!header) return Promise.reject(new Error('missing x-spark-signature'));
const json = JSON.parse(text); // will load details and check signature:
return Spark.getWebhookDetails(json).then(({ secret }) => {
if (!secret) return Promise.reject(new Error('missing webhook secret'));
const stream = OpenSSL.createHmac('sha1', secret).update(text, 'utf8');
const [digest, signature] = [stream.digest(), Buffer.from(header, 'hex')];
if (OpenSSL.timingSafeEqual(digest, signature)) return json; // or:
return Promise.reject(new Error('invalid x-spark-signature'));
});
};
const validate = req => {
/* istanbul ignore next */
if (validate.cache.has(req)) {
// coalesce calls on same req:
return validate.cache.get(req);
}
if (!(_.get(req, 'req', req) instanceof HTTP.IncomingMessage)) {
return Promise.reject(new Error('cannot validate request'));
}
const promise = Promise.resolve().then(() => {
// unofficially support koa-bodyparser
const RAW_BODY_REQUEST_KEY = 'rawBody';
/* istanbul ignore next */
if (RAW_BODY_REQUEST_KEY in req) {
const text = req[RAW_BODY_REQUEST_KEY]; // upstream
return validateIncomingWebhook(text, req.headers);
}
return BodyParser.text(req).then(text => {
req[RAW_BODY_REQUEST_KEY] = text; // downstream
return validateIncomingWebhook(text, req.headers);
});
}).catch(reason => {
// auto-evict on rejection:
validate.cache.delete(req);
return Promise.reject(reason);
});
validate.cache.set(req, promise);
return promise;
};
/*
* WeakMap is a perfect default RequestCache
* Promise(s) will be GC'd along with req(s)
* due to eviction semantics of "weak" key(s)
*/
validate.cache = new Spark.RequestCache();
Object.defineProperty(validate, 'loaders', { value: loaders });
Object.defineProperty(Spark, 'validate', { value: validate });
module.exports = Object.assign(Spark, { default: validate });