evrythng-hub
Version:
Evrythng.js Hub plugin for transparent local Thng-Hub requests
710 lines (585 loc) • 21.5 kB
JavaScript
// ## EVRYTHNG-HUB.JS Plugin
// This plugin allows the evrythng.js to communicate to a local API
// if available (e.g. inside home), and retry to the cloud if the specified
// API is not available (e.g. outside home).
// The following methods support local HTTP requests:
// - .thng('{thngId}').read()
// - .thng('{thngId}').property('{propertyName}').read()
// - .thng('{thngId}').property('{propertyName}').create()
// - .thng('{thngId}').property('{propertyName}').update()
// - .thng('{thngId}').action('{actionType}').read()
// - .thng('{thngId}').action('{actionType}').create()
// - .thng('{thngId}').action('{actionType}', '{actionId}').read()
// - .collection('{collectionId}').read()
// - .collection('{collectionId}').thng().read()
// - .collection('{collectionId}').action('{actionType}').read()
// - .collection('{collectionId}').action('{actionType}').create()
// - .collection('{collectionId}').action('{actionType}', '{actionId}').read()
// - .actionType().read()
// ### Pubsub
// Can be used on top of evrythng-ws.js and evrythng-mqtt.js to provide
// the ability to connect to local WS and MQTT servers as well.
// ### Encryption
// evrythng-hub.js fetches the distributed hub configuration from the cloud
// on request. The user can then specify which hub they want to use as
// the local gateway. The plugin uses the configuration specified in the
// Hub's custom fields, including encryption.
(function (root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// AMD
// Define optional RequireJS plugin first.
// http://stackoverflow.com/a/27422370/130480
define("optional", [], {
load: function (moduleName, parentRequire, onload) {
var onLoadSuccess = function (moduleInstance) {
onload(moduleInstance);
};
var onLoadFailure = function (err) {
var failedId = err.requireModules && err.requireModules[0];
// Undefine the module to cleanup internal stuff.
requirejs.undef(failedId);
define(failedId, [], function () {
});
parentRequire([failedId], onLoadSuccess);
};
parentRequire([moduleName], onLoadSuccess, onLoadFailure);
}
});
// evrythng-ws, jsrsasign, node-jose-browserified
// deps are optional.
define([
'optional!evrythng-ws',
'optional!jsrsasign',
'optional!node-jose-browserified'
], function (WS, JWT, Jose) {
return factory(undefined, WS, JWT, Jose);
});
} else if (typeof exports === 'object') {
// Node/CommonJS
// evrythng-mqtt, jsrsasign, node-jose deps are optional.
var MQTT, JWT, Jose, tryLoading;
tryLoading = function (name) {
var module;
try {
module = require(name);
} catch (e) {
// do nothing
} finally {
return module;
}
};
MQTT = tryLoading('evrythng-mqtt');
JWT = tryLoading('jsrsasign');
Jose = tryLoading('node-jose');
module.exports = factory(MQTT, undefined, JWT, Jose);
} else {
// Browser globals
root.EVT.Hub = root.Evrythng.Hub = factory(undefined, root.EVT.WS, root.KJUR, root.nodeJose);
}
}(this, function (MQTT, WS, JWT, Jose) {
'use strict';
var version = '2.0.0';
// Setup default settings:
// - _**timeout**: Timeout in milliseconds for local requests, before switching to remote_
// - _**remote**: Explicitly switch the requests to use the default remote/cloud URLs_
// - _**targetHub**: Hub to be used as local gateway_.
var defaultSettings = {
timeout: 1000,
remote: false,
targetHub: null
};
// List of local endpoints supported by thng-hub. We use paths checking
// on the global EVT.api() method, instead of patching all resources
// since the user can make these calls using the raw api method.
var localEndpoints = [
{
//.thng().read()
path: /^\/thngs$/,
method: ['get']
}, {
//.thng('{thngId}').read()
path: /^\/thngs\/[^\/]+$/,
method: ['get']
}, {
//.thng('{thngId}').property().read/create/update()
path: /^\/thngs\/[^\/]+\/properties$/,
method: ['post', 'get', 'put']
}, {
//.thng('{thngId}').property('{propertyName}').read/update()
path: /^\/thngs\/[^\/]+\/properties\/[^\/]+$/,
method: ['get', 'put']
}, {
//.thng('{thngId}').action('{actionType}').read/create()
path: /^\/thngs\/[^\/]+\/actions\/[^\/]+$/,
method: ['post', 'get']
}, {
//.thng('{thngId}').action('{actionType}', '{actionId}').read()
path: /^\/thngs\/[^\/]+\/actions\/[^\/]+\/[^\/]+$/,
method: ['get']
}, {
//.collection().read()
path: /^\/collections$/,
method: ['get']
}, {
//.collection('{collectionId}').read()
path: /^\/collections\/[^\/]+$/,
method: ['get']
}, {
//.collection('{collectionId}').thng().read()
path: /^\/collections\/[^\/]+\/thngs$/,
method: ['get']
}, {
//.collection('{collectionId}').action('{actionType}').read/create()
path: /^\/collections\/[^\/]+\/actions\/[^\/]+$/,
method: ['post', 'get']
}, {
//.collection('{collectionId}').action('{actionType}', '{actionId}').read()
path: /^\/collections\/[^\/]+\/actions\/[^\/]+\/[^\/]+$/,
method: ['get']
}, {
// .actionType().read()
path: /^\/actions$/,
method: ['get']
}
];
// Pubsub plugin used in conjunction with the Hub.
var pubSubPlugin = MQTT || WS;
// Cached map for {'apiKey': {'hubId': 'encryptedApiKey'} }
var encryptedKeyMap = {};
// Check if a variable is an Object
function isObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
// Build url based on protocol, ip and port.
function buildUrl(protocol, ip, port) {
var url = protocol + '://' + ip + ':' + port;
if(protocol !== 'http'){
url += '/mqtt';
}
return url;
}
// Generate a unique id to create a jti claim in jsonwebtoken.
function generateId() {
return Jose.util.base64url.encode(Jose.util.randomBytes(32));
}
// Init secret key. Needed for encryption/decryption.
function initSecretKey(key) {
return Jose.JWK.asKey(key);
}
// Create encryption (JWE).
function encrypt(input, key, options) {
if (isObject(input)) {
input = JSON.stringify(input);
}
return Jose.JWE
.createEncrypt(options || {}, key)
.update(input)
.final();
}
// Create decryption (JWE). Resolved with plaintext of the result.
function decrypt(input, key) {
return Jose.JWE
.createDecrypt(key)
.decrypt(input)
.then(function (result) {
return result.plaintext.toString();
});
}
// Create a jsonwebtoken and sign it using secret key ID.
function createJWT(options, kid) {
var header = {alg: 'HS256', typ: 'JWT'};
var claims = {
iss: options.iss,
aud: options.aud,
sub: options.sub,
jti: options.jti || generateId(),
data: options.data
};
return JWT.jws.JWS.sign('HS256', JSON.stringify(header), JSON.stringify(claims), kid);
}
// Verify signature and validate a jsonwebtoken.
function verifyJWT(input, kid, validateOptions) {
validateOptions = validateOptions || {};
validateOptions.alg = ['HS256'];
return JWT.jws.JWS.verifyJWT(input, kid, validateOptions);
}
// Split JWT into header and payload, decode them.
function decodeJWT(input) {
input = input.split('.');
return {
header: JSON.parse(decode(input[0])),
payload: JSON.parse(decode(input[1]))
};
}
// Syntax sugar for encrypting header.
function encryptAuthorization(authorization, secretKey) {
return encrypt(authorization, secretKey, { format: 'compact' });
}
// Create jwt first, then encrypt payload.
function encryptPayload(payload, secretKey, options) {
var jwt = createJWT({
iss: options.iss,
aud: options.aud,
sub: options.sub,
data: payload
}, secretKey.kid);
return encrypt(jwt, secretKey);
}
// Decrypt the payload first, then verify jsonwebtoken and decode it.
function decryptPayload(payload, secretKey, options) {
return decrypt(payload, secretKey)
.then(function (jwt) {
var isValid = verifyJWT(jwt, secretKey.kid, {
iss: [options.iss],
aud: [options.aud],
sub: [options.sub]
});
if (isValid) {
return decodeJWT(jwt).payload.data;
} else {
throw new Error('Unable to verify jsonwebtoken.');
}
});
}
// Decode base64 string.
function decode(input) {
return Jose.util.base64url.decode(input, 'utf8');
}
// Check if url and method are supported by the hub endpoints.
function isSupported(url, method) {
method = method || 'get';
// Get only the path (no query strings, etc.)
var path = /^(.*?)(\?|$)/.exec(url)[1];
// Check if it is a local request. Path and method should
// exist in the mapping above.
for (var i = 0; i < localEndpoints.length; i++) {
if (localEndpoints[i].path.test(path) &&
localEndpoints[i].method.indexOf(method) !== -1) {
return true;
}
}
return false;
}
// Plugin API
var EVTHubPlugin = {
version: version,
settings: defaultSettings,
setup: function (customSettings) {
if (isObject(customSettings)) {
// Override default settings with new ones
for (var i in customSettings) {
if (customSettings.hasOwnProperty(i)) {
this.settings[i] = customSettings[i];
}
}
} else {
throw new TypeError('Setup should be called with an options object.');
}
// Init secret key if setting up a new targetHub
if(customSettings.targetHub && customSettings.targetHub.customFields.key){
this.settings.targetHub.customFields.secretKey =
initSecretKey(customSettings.targetHub.customFields.key);
}
return this.settings;
},
install: function (EVT, Scope, Resource, Utils, Logger) {
var $this = this;
var original = {
api: EVT.api,
subscribe: Resource.prototype.subscribe,
publish: Resource.prototype.publish
};
EVT.api = localApi;
Scope.prototype.getAvailableHubs = getAvailableHubs;
// Patch Pubsub methods. Unsubscribe does not create any connection.
if (pubSubPlugin) {
Resource.prototype.subscribe = localPubSub('subscribe');
Resource.prototype.publish = localPubSub('publish');
}
// PATCHES
function localApi(options) {
var args = arguments;
var previousInterceptors = (options.interceptors || [])
.concat(EVT.settings.interceptors || []);
var interceptors = [{
request: filterRemoteRequest
}, {
request: getRequestConfig('http')
}, {
request: buildEncrypted
}, {
request: function (opts) {
if(!opts.remote) {
opts.timeout = $this.settings.timeout;
}
}
}];
options.interceptors = previousInterceptors.concat(interceptors);
return original.api.apply(EVT, args)
.catch(function (err) {
// Request has been cancelled by pubSub plugin. Do nothing.
if (pubSubPlugin && err && err.cancelled) {
return;
}
// Local REST is not available. Switch to remote.
// Allow local requests to fail (404, 400, etc.)
// without falling back to remote.
if(err && err.status === 0){
Logger.info('Local hub is unavailable. Switching to remote...');
delete options.timeout;
options.interceptors = previousInterceptors;
return original.api.apply(EVT, args);
} else {
throw err;
}
});
}
function localPubSub(method) {
return function () {
var args = Array.prototype.slice.call(arguments),
$resource = this,
protocol = pubSubPlugin === MQTT? 'mqtt' : 'ws',
remoteOptions = {
apiUrl: pubSubPlugin.settings.apiUrl,
url: $resource.path,
authorization: $resource.scope.apiKey,
targetHub: $this.settings.targetHub
},
options = {},
cachedData = args[0];
var interceptors = [{
request: filterRemoteRequest
}, {
request: getRequestConfig(protocol)
}, {
request: buildEncrypted
}, {
request: function (opts, cancel) {
Utils.extend(options, opts, true);
cancel();
}
}];
// Get configuration and encrypted options by calling .api()
// with request interceptors.
return original.api({
authorization: $resource.scope.apiKey,
url: $resource.path,
interceptors: interceptors
}).catch(function () {
if(!options.remote){
var connectOptions = {
authorization: options.headers.authorization,
apiUrl: options.apiUrl
};
// Add connect options in second argument.
// First is always the callback (subscribe) or
// the message (publish).
if(Utils.isFunction(args[1])){
args.splice(1, 0, connectOptions);
} else {
args[1] = Utils.extend(args[1], connectOptions);
}
// Decrypt message before calling message callback.
if (method === 'subscribe' && $this.settings.targetHub.customFields.security.response) {
args[0] = secureCallback(args[0], remoteOptions);
}
}
return original[method].apply($resource, args)
.catch(function () {
// Local PubSub is not available. Switch to remote.
Logger.info('Local hub is unavailable. Switching to remote...');
// Reset connectOptions and data/callback.
args[0] = cachedData;
args[1] = {};
return original[method].apply($resource, args);
});
});
};
}
function getAvailableHubs() {
// This runs in the context of a Scope which contains an apiKey.
return Promise.resolve()
.then(getDistributedCollectionId(this.apiKey))
.then(getDistributedHubs(this.apiKey));
}
// INTERCEPTORS
// Check if url is supported and return regex object.
function filterRemoteRequest(options) {
options.remote = isRemote(options) || !isSupported(options.url, options.method);
return options;
}
// Get hub configuration and apiUrl for protocol.
function getRequestConfig(protocol){
return function (options) {
if(options.remote){
return options;
}
if(!$this.settings.targetHub){
throw new Error('There is no "targetHub" property in the settings.');
}
options.apiUrl = buildUrl(
protocol,
$this.settings.targetHub.customFields.ip.v4,
$this.settings.targetHub.customFields.ports[protocol]
);
return options;
};
}
// Encrypt apiKey/payload (secure mode)
function buildEncrypted(options) {
// is remote, or there is no security
if (options.remote ||
!($this.settings.targetHub.customFields.security.request ||
$this.settings.targetHub.customFields.security.response)) {
return options;
}
if(!$this.settings.targetHub.customFields.secretKey){
throw new Error('THNGHUB=[' + $this.settings.targetHub.id + '] requires ' +
'encryption, but there is no encryption key.');
}
var originalOptions = {
authorization: options.authorization,
url: options.url,
targetHub: $this.settings.targetHub
};
return Promise.all([
encryptApiKey(options.authorization, options),
encryptData(options.data, options)
]).then(function (encrypted) {
var encryptedKey = encrypted[0];
var encryptedData = encrypted[1];
options.headers.authorization = encryptedKey;
if(encryptedData) {
options.data = encryptedData;
}
// Add response interceptor with closure to original options.
if($this.settings.targetHub.customFields.security.response){
options.interceptors = [{
response: function(res) {
return decryptData(res, originalOptions);
}
}];
}
return options;
});
}
// HELPERS
// Returns current option's remote setting or the global setting.
function isRemote(options) {
return options.remote !== undefined ? options.remote : $this.settings.remote;
}
// Get distrubuted collection from cloud using scope's apiKey.
function getDistributedCollectionId(apiKey) {
return function () {
return original.api({
url: '/collections',
params: { filter: 'tags=HubDistribution' },
authorization: apiKey
}).then(function (cols) {
if(!cols.length) {
throw new Error('There is no distributed collection in this project.');
}
return cols[0].id;
});
};
}
// Get all hubs from distributed collection using scope's apiKey.
function getDistributedHubs(apiKey) {
return function (collectionId) {
return original.api({
url: '/collections/' + collectionId + '/thngs',
authorization: apiKey
}).then(function (thngs) {
if(!thngs.length) {
throw new Error('There are no THNGHUBS in the distributed collection.');
}
// Only return hubs that are connected
return thngs.filter(function (hub) {
return hub.properties['~connected'];
});
});
};
}
// Encrypt apiKey or get from cache.
function encryptApiKey(apiKey, options) {
if(encryptedKeyMap[apiKey] &&
encryptedKeyMap[apiKey][$this.settings.targetHub.id]){
// pre cached key for this hub
return Promise.resolve(encryptedKeyMap[apiKey][$this.settings.targetHub.id]);
} else {
// encrypt and cache key for this hub
return $this.settings.targetHub.customFields.secretKey
.then(function (secretKey) {
return encryptAuthorization(apiKey, secretKey);
})
.then(function (encryptedApiKey) {
encryptedKeyMap[apiKey] = encryptedKeyMap[options.authorization] || {};
encryptedKeyMap[apiKey][$this.settings.targetHub.id] = encryptedApiKey;
return encryptedApiKey;
});
}
}
// Encrypt data if any.
function encryptData(data, options) {
if(data){
return $this.settings.targetHub.customFields.secretKey
.then(function (secretKey) {
return encryptPayload(data, secretKey, {
iss: options.authorization,
aud: $this.settings.targetHub.id,
sub: options.url
});
});
}
}
// Decrypt data if any.
function decryptData(data, options) {
if (data) {
return $this.settings.targetHub.customFields.secretKey
.then(function (secretKey) {
return decryptPayload(data, secretKey, {
aud: options.authorization,
iss: options.targetHub.id,
sub: options.url
});
});
}
}
// Decrypt payload on subscription callbacks in secure environments
function secureCallback(cb, options) {
return function (msg) {
var resource = msg.resource;
return $this.settings.targetHub.customFields.secretKey
.then(function (secretKey) {
return decryptPayload(msg, secretKey, {
aud: options.authorization,
iss: options.targetHub.id,
sub: options.url
});
}).then(function (decrypted) {
return cb(resource.parse(decrypted));
});
};
}
}
};
// Attach useful utils methods to the plugin.
if (JWT && Jose) {
EVTHubPlugin.utils = {
generateId: generateId,
initSecretKey: initSecretKey,
encrypt: encrypt,
decrypt: decrypt,
createJWT: createJWT,
verifyJWT: verifyJWT,
decodeJWT: decodeJWT,
encryptAuthorization: encryptAuthorization,
encryptPayload: encryptPayload,
decryptPayload: decryptPayload
};
}
// Modules that this plugin requires. Injected into the install method.
EVTHubPlugin.$inject = ['core', 'scope/scope', 'resource', 'utils', 'logger'];
return EVTHubPlugin;
}));