nitrogen
Version:
Nitrogen is a platform for building connected devices. Nitrogen provides the authentication, authorization, and real time message passing framework so that you can focus on your device and application. All with a consistent development platform that lev
348 lines (274 loc) • 12.2 kB
JavaScript
var request = require('request')
, Principal = require('./principal')
, Session = require('./session')
, MemoryStore = require('./memoryStore');
/**
* A Service is a Nitrogen endpoint that a Session is established against for interactions with
* it. How that Session is established depends on the Principal type. For device and service
* principals, authentication is done based on a shared secret between the device and the
* service. For user principals, authentication is via email and password. Sessions can also
* be resumed if the principal has stored an authToken. The Service object is also responsible
* for querying the headwaiter endpoint to fetch the service endpoints that this Session should
* use.
*
* @class Service
* @namespace nitrogen
**/
function Service(config) {
this.config = config || {};
if (!this.config.store) this.config.store = new MemoryStore();
this.config.host = this.config.host || 'api.nitrogen.io';
this.config.protocol = this.config.protocol || 'https';
this.config.http_port = this.config.http_port || 443;
this.config.max_session_lifetime = Service.MAX_SESSION_LIFETIME_DEFAULT;
this.config.base_url = this.config.protocol + "://" + this.config.host + ":" + this.config.http_port + "/api/v1";
if (!this.config.endpoints) this.config.endpoints = {};
for (var key in config.endpoints) {
this.config.endpoints[key] = config.endpoints[key];
}
if (!this.config.endpoints.headwaiter) {
this.config.endpoints.headwaiter = this.config.base_url + "/headwaiter";
}
// Need this for password reset. Can be overridden by response from headwaiter.
if (!this.config.endpoints.principals) {
this.config.endpoints.principals = this.config.base_url + "/principals";
}
this.store = config.store;
}
Service.BACKOFF_RATE_MILLIS = 500;
Service.MAXIMUM_BACKOFF_STEPS = 7;
Service.MAX_SESSION_LIFETIME_DEFAULT = 24 * 60 * 60 * 1000; // ms
/**
* Authenticate this principal with the Nitrogen service. The mechanism used to authenticate
* depends on the type of principal. For users, an email and password is used. For other principals
* public key encryption is verify a signed nonce value for authentication.
*
* @method authenticate
* @async
* @param {Object} principal The principal to authenticate with this service. The principal should include the email/password for a user principal or the private_key for other principal types.
* @param {Function} callback Callback function with signature f(err, session, principal).
**/
Service.prototype.authenticate = function(principal, callback) {
this.authenticateSession(principal, principal.authenticate, callback);
};
/**
* Creates the principal with this service.
*
* @method create
* @async
* @param {Object} principal The principal to create with this service. It should include email/password for user principal types.
* @param {Function} callback Callback function with signature f(err, session, principal).
**/
Service.prototype.create = function(principal, callback) {
this.authenticateSession(principal, principal.create, callback);
};
/**
* Attempts to resume a session for this principal using a saved accessToken.
*
* @method resume
* @async
* @param {Object} principal The principal to resume the session with this service.
* @param {Function} callback Callback function with signature f(err, session, principal).
**/
Service.prototype.resume = function(principal, callback) {
if (!this.store) return callback(new Error('No store configured with service.'));
this.authenticateSession(principal, principal.resume, function(err, session, principal) {
if (err) return callback(err);
Principal.findById(session, principal.id, function(err, loadedPrincipal) {
if (err) return callback(err);
loadedPrincipal.nickname = principal.nickname;
return callback(err, session, loadedPrincipal);
});
});
};
/**
* Connect attempts to authenicate the principal with the service. If the principal
* isn't provisioned with the service, it automatically creates the principal with the
* service.
*
* @method connect
* @async
* @param {Object} principal The principal to connect with this service.
* @param {Function} callback Callback function with signature f(err, session, principal).
**/
Service.prototype.connect = function(principal, callback) {
var self = this;
this.store.get(principal.toStoreId(), function(err, storedPrincipal) {
if (storedPrincipal) {
principal.updateAttributes(storedPrincipal);
self.restartOnFailureAuthWrapper(principal, principal.authenticate, callback);
} else {
self.restartOnFailureAuthWrapper(principal, principal.create, callback);
}
});
};
function asyncWhilst(test, iterator, callback) {
if (test()) {
iterator(function (err) {
if (err) {
return callback(err);
}
asyncWhilst(test, iterator, callback);
});
} else {
callback();
}
}
Service.prototype.restartOnFailureAuthWrapper = function(principal, initialOp, sessionCallback) {
var self = this;
var authOp = initialOp;
asyncWhilst(
function() { return true; },
function(restartCallback) {
self.authOpWithRetry(principal, authOp, function(err, session, principal) {
if (err) return sessionCallback(err);
session.onFailure(function() {
session.log.error('session failure: restarting');
session.stop();
authOp = principal.authenticate;
return restartCallback();
});
return sessionCallback(err, session, principal);
});
},
function(err) {
// should never get here
}
);
};
Service.prototype.authOpWithRetry = function(principal, authOperation, callback) {
var self = this;
var failures = 0;
var successful = false;
var backoffMillis = 0;
var err;
var session;
asyncWhilst(
function () { return !successful; },
function (retryCallback) {
if (backoffMillis > 0.0)
console.log('service: retrying auth request after backoff of ' + backoffMillis + ' ms.');
setTimeout(function() {
self.authenticateSession(principal, authOperation, function(e, s, p) {
successful = !e;
err = e;
if (!successful) {
console.log('service: authentication failed: ' + JSON.stringify(err));
failures += 1;
console.log('service: ' + failures + ' consecutive auth failures.');
var backoffAmount = Math.min(failures, Service.MAXIMUM_BACKOFF_STEPS);
backoffMillis = Math.pow(2, backoffAmount) * Service.BACKOFF_RATE_MILLIS * (1 + Math.random());
backoffMillis = Math.floor(backoffMillis);
} else {
session = s;
principal = p;
}
return retryCallback();
});
}, backoffMillis);
},
function (e) {
return callback(err, session, principal);
}
);
};
/**
* Internal method to run all the common steps of authentication against a Nitrogen service.
*
* @method authenticateSession
* @async
* @private
* @param {Object} principal The principal to connect with this service.
* @param {Object} authOperation The authorization method on the principal that is used to .
* @param {Function} callback Callback function with signature f(err, session, principal).
**/
Service.prototype.authenticateSession = function(principal, authOperation, callback) {
var self = this;
this.configure(principal, function(err, config) {
if (err) return callback(err);
self.config = config;
authOperation.bind(principal)(self.config, function(err, principal, accessToken) {
if (err) return callback(err);
if (!principal) return callback(new Error("authentication failed: no principal returned"));
if (!accessToken) return callback(new Error("authentication failed: no accessToken returned"));
if (self.config.log_levels && self.config.log_levels.indexOf('debug') !== -1) console.log('principal: authenticated.');
if (principal.claim_code) {
console.log('This principal (' + principal.id + ') can be claimed using code: ' + principal.claim_code);
}
self.store.set(principal.toStoreId(), principal.toStoreObject(), function(err) {
if (err) return callback(err);
var session = Session.startSession(self, principal, accessToken);
if (self.config.max_session_lifetime)
setInterval(function() { session.onFailure(); }, config.max_session_lifetime);
callback(null, session, principal);
});
});
});
};
/**
* Impersonate a principal using the passed session
*
* @method impersonate
* @async
* @param {Object} session The session to use to authorize this impersonation
* @param {Object} principal The principal to impersonate with this service.
* @param {Function} callback Callback function with signature f(err, session, principal).
**/
Service.prototype.impersonate = function(session, principalId, callback) {
var self = this;
Principal.impersonate(session, principalId, function(err, impersonatedPrincipal, accessToken) {
if (err) return callback(err);
impersonatedPrincipal.accessToken = accessToken;
var impersonatedSession = Session.startSession(self, impersonatedPrincipal, accessToken);
// configure the impersonatedPrincipal session in case the principal has a different config.
self.configure(impersonatedPrincipal, function(err, config) {
if (err) return callback(err);
impersonatedSession.service.config = config;
callback(null, impersonatedSession, impersonatedPrincipal);
});
});
};
/**
* Fetch the endpoint configuration for this service for this user. Before authenticating a principal, we first
* ask the service to return the set of endpoints that we should for this principal to talk to the Nitrogen service. Note,
* this might actually not be the same service, as Nitrogen may redirect clients to a different service or different endpoints.
*
* @method configure
* @async
* @private
* @param {Object} config The default configuration to use to connect to Nitrogen.
* @param {Object} principal The principal to use configure this service.
* @param {Function} callback Callback function with signature f(err, configuration).
**/
Service.prototype.configure = function(principal, callback) {
var self = this;
var headwaiter_url = this.config.endpoints.headwaiter;
if (principal) {
if (principal.is('user') && principal.email) {
headwaiter_url += "?email=" + principal.email;
} else if (principal.id) {
headwaiter_url += "?principal_id=" + principal.id;
}
}
request.get({ url: headwaiter_url, json: true }, function(err, resp, body) {
if (err) return callback(err);
if (resp.statusCode != 200) return callback(JSON.stringify(body.error) || resp.statusCode);
for (var key in body.endpoints) {
self.config.endpoints[key] = body.endpoints[key];
}
self.config.nonce = body.nonce;
callback(null, self.config);
});
};
/**
* Clear all of the credentials for a particular principal.
*
* @method clearCredentials
* @private
* @param {Object} principal The principal to clear credentials for.
**/
Service.prototype.clearCredentials = function(principal) {
if (!this.store) return;
this.store.delete(principal.toStoreId());
};
module.exports = Service;