passport-fellowshipone
Version:
Fellowship One (F1) authentication strategy for Passport.
350 lines (308 loc) • 12.4 kB
JavaScript
/**
* Module dependencies.
*/
var util = require('util'),
OAuthStrategy = require('passport-oauth').OAuthStrategy,
InternalOAuthError = require('passport-oauth').InternalOAuthError,
querystring = require('querystring'),
URL = require('url'),
URI = require('URIjs'),
URITemplate = require('URIjs/src/URITemplate'),
async = require('async'),
request = require('request');
/**
* `Strategy` constructor.
*
* The FellowshipOne authentication strategy authenticates requests by delegating
* to Fellowship One using the OAuth 1.0a protocol.
*
* Applications must supply a `verify` callback which accepts a `token`,
* `tokenSecret` and service-specific `profile`, and then calls the `done`
* callback supplying a `user`, which should be set to `false` if the
* credentials are not valid. If an exception occured, `err` should be set.
*
* Options:
* - `churchCode` Your Fellowship One church code
* - `staging` Whether we're using staging or production
* - `consumerKey` Fellowship One Developer Key
* - `consumerSecret` Fellowship One Secret Key
* - `callbackURL` URL to which Fellowship One will redirect the user after obtaining authorization
*
* Examples:
*
* passport.use(new F1Strategy({
* churchCode: 'MYCHURCH',
* staging: true,
* consumerKey: '123',
* consumerSecret: 'xxx'
* callbackURL: 'https://www.example.net/auth/fellowshipone/callback'
* },
* function(token, tokenSecret, profile, done) {
* User.findOrCreate(..., function (err, user) {
* done(err, user);
* });
* }
* ));
*
* @param {Object} options
* @param {Function} verify
* @api public
*/
function Strategy(options, verify) {
options = options || {}
options.churchCode = options.churchCode
options.apiURL = expand(options.apiURL || 'https://{churchCode}.fellowshiponeapi.com/v1', options)
options.requestTokenURL = expand(options.requestTokenURL || options.apiURL + '/Tokens/RequestToken', options)
options.accessTokenURL = expand(options.accessTokenURL || options.apiURL + '/Tokens/AccessToken', options)
options.userAuthorizationURL = expand(options.userAuthorizationURL || options.apiURL + '/PortalUser/Login', options)
OAuthStrategy.call(this, options, verify)
this.options = options
this.name = 'fellowshipone'
// Override oauth.getOAuthAccessToken so that we can get the user profile from the
// response headers.
this._oauth.getOAuthAccessToken = this._getOAuthAccessToken.bind(this._oauth)
// Override oauth._performSecureRequest to account for https://github.com/ciaranj/node-oauth/issues/182
this._oauth._performSecureRequest = this._performSecureRequest.bind(this._oauth)
}
/**
* Inherit from `OAuthStrategy`.
*/
util.inherits(Strategy, OAuthStrategy);
var expand = function(uri, options) {
return URI.expand(uri, options).normalize().toString()
}
// Override oauth.getOAuthAccessToken so that we can get the user profile from the
// response headers.
Strategy.prototype._getOAuthAccessToken = function(oauth_token, oauth_token_secret, oauth_verifier, callback) {
/* jshint sub: true */
var extraParams = {};
if (typeof oauth_verifier == "function") {
callback = oauth_verifier;
} else {
extraParams.oauth_verifier = oauth_verifier;
}
this._performSecureRequest(oauth_token, oauth_token_secret, this._clientOptions.accessTokenHttpMethod, this._accessUrl, extraParams, null, null,
function(error, data, response) {
if (error) callback(error);
else {
var results = querystring.parse(data);
var oauth_access_token = results["oauth_token"];
delete results["oauth_token"];
var oauth_access_token_secret = results["oauth_token_secret"];
delete results["oauth_token_secret"];
// this is the only customization really
results.userURL = response.headers['content-location']
callback(null, oauth_access_token, oauth_access_token_secret, results);
}
})
/* jshint sub: false */
}
// Override oauth._performSecureRequest to account for https://github.com/ciaranj/node-oauth/issues/182
Strategy.prototype._performSecureRequest = function(oauth_token, oauth_token_secret, method, url, extra_params, post_body, post_content_type, callback) {
/* jshint shadow: true, sub: true, eqnull: true */
var orderedParameters = this._prepareParameters(oauth_token, oauth_token_secret, method, url, extra_params);
if (!post_content_type) {
post_content_type = "application/x-www-form-urlencoded";
}
var parsedUrl = URL.parse(url, false);
if (parsedUrl.protocol == "http:" && !parsedUrl.port) parsedUrl.port = 80;
if (parsedUrl.protocol == "https:" && !parsedUrl.port) parsedUrl.port = 443;
var headers = {};
var authorization = this._buildAuthorizationHeaders(orderedParameters);
if (this._isEcho) {
headers["X-Verify-Credentials-Authorization"] = authorization;
} else {
headers["Authorization"] = authorization;
}
headers["Host"] = parsedUrl.host
for (var key in this._headers) {
if (this._headers.hasOwnProperty(key)) {
headers[key] = this._headers[key];
}
}
// Filter out any passed extra_params that are really to do with OAuth
for (var key in extra_params) {
if (this._isParameterNameAnOAuthParameter(key)) {
delete extra_params[key];
}
}
if ((method == "POST" || method == "PUT") && (post_body == null && extra_params != null)) {
// Fix the mismatch between the output of querystring.stringify() and this._encodeData()
post_body = querystring.stringify(extra_params)
.replace(/\!/g, "%21")
.replace(/\'/g, "%27")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29")
.replace(/\*/g, "%2A");
}
if (post_body) {
if (Buffer.isBuffer(post_body)) {
headers["Content-length"] = post_body.length;
} else {
headers["Content-length"] = Buffer.byteLength(post_body);
}
headers["Content-Type"] = post_content_type;
} else {
headers["Content-length"] = 0;
}
var path;
if (!parsedUrl.pathname || parsedUrl.pathname === "") parsedUrl.pathname = "/";
if (parsedUrl.query) path = parsedUrl.pathname + "?" + parsedUrl.query;
else path = parsedUrl.pathname;
var request;
if (parsedUrl.protocol == "https:") {
request = this._createClient(parsedUrl.port, parsedUrl.hostname, method, path, headers, true);
} else {
request = this._createClient(parsedUrl.port, parsedUrl.hostname, method, path, headers);
}
var clientOptions = this._clientOptions;
if (callback) {
var data = "";
var self = this;
// Some hosts *cough* google appear to close the connection early / send no content-length header
// allow this behaviour.
var allowEarlyClose = false; //OAuthUtils.isAnEarlyCloseHost(parsedUrl.hostname);
var callbackCalled = false;
var passBackControl = function(response) {
if (!callbackCalled) {
callbackCalled = true;
if (response.statusCode >= 200 && response.statusCode <= 299) {
callback(null, data, response);
} else {
// Follow 301 or 302 redirects with Location HTTP header
if ((response.statusCode == 301 || response.statusCode == 302) && clientOptions.followRedirects && response.headers && response.headers.location) {
self._performSecureRequest(oauth_token, oauth_token_secret, method, response.headers.location, extra_params, post_body, post_content_type, callback);
} else {
callback({
statusCode: response.statusCode,
data: data
}, data, response);
}
}
}
}
request.on('response', function(response) {
response.setEncoding('utf8');
response.on('data', function(chunk) {
data += chunk;
});
response.on('end', function() {
passBackControl(response);
});
response.on('close', function() {
if (allowEarlyClose) {
passBackControl(response);
}
});
});
request.on("error", function(err) {
if (!callbackCalled) {
callbackCalled = true;
callback(err)
}
});
if ((method === "POST" || method === "PUT") && post_body != null && post_body !== "") {
request.write(post_body);
}
request.end();
} else {
if ((method === "POST" || method === "PUT") && post_body != null && post_body !== "") {
request.write(post_body);
}
return request;
}
/* jshint shadow: false, sub: false, eqnull: false */
return;
}
/**
* Implement this so that we can send the callback... This doesn't seem to be
* working right for the oauth module...
*/
Strategy.prototype.userAuthorizationParams = function(options) {
return {
oauth_callback: this._callbackURL
}
}
// retrieve a profile-related object asynchronously and yield its body
Strategy.prototype._retrieve = function(oauth, url, callback) {
process.nextTick(function() {
request.get(url, {
oauth: oauth,
json: true
}, function(err, res, body) {
if (err) {
console.error(err)
return callback(new InternalOAuthError('failed to fetch user profile', err))
}
if (res.statusCode > 299) {
err = new InternalOAuthError('error ' + res.statusCode + ' while fetching user profile: ' + body)
err.statusCode = res.statusCode
console.error('failed to fetch user profile: %j', err)
return callback(err)
}
if (!body) return callback(new InternalOAuthError('Fellowship One returned invalid reply object %s', body))
callback(null, body)
})
})
}
// transform an array of [ {person:...}, {communications:...}] into a profile
Strategy.prototype.transform = function(err, items, done) {
if (err) return done(err)
if (!items[0].person) return done(new InternalOAuthError('Fellowship One returned invalid reply object %s', items[0]))
if (!items[1].communications) return done(new InternalOAuthError('Fellowship One returned invalid reply object %s', items[1]))
var user = items[0].person
var profile = {}
profile.id = Number(user['@id'])
profile.uri = user['@uri']
profile.displayName = user.goesByName ? user.goesByName : user.firstName
profile.fullName = profile.displayName + ' ' + user.lastName
var communication = items[1].communications.communication || []
var emails = communication.reduce(function(memo, comm) {
if (comm.communicationGeneralType === 'Email')
memo.push({
value: comm.communicationValue,
type: comm.communicationType.name,
preferred: comm.preferred === "true"
})
return memo
}, [])
var email = emails.reduce(function(memo, email) {
if (email.preferred) return email
else if (memo) return memo
else return email
}, undefined)
if (email && email.value) profile.email = email.value
done(null, profile)
}
/**
* Retrieve user profile from Fellowship One.
*
* This function constructs a normalized profile, with the following properties:
*
* - `id`
* - `displayName`
* - `email`
*
* @param {String} token
* @param {String} tokenSecret
* @param {Object} params - this should have a userURL property, injected by the _oauth.getOAuthAccessToken call
* @param {Function} done
* @api protected
*/
Strategy.prototype.userProfile = function(token, tokenSecret, params, done) {
if (!params || !params.userURL) return done(null, {})
var oauth = {
consumer_key: this.options.consumerKey,
consumer_secret: this.options.consumerSecret,
token: token,
token_secret: tokenSecret
}
// turn the person and communications record into a profile
async.map([params.userURL, params.userURL + '/Communications'], this._retrieve.bind(this, oauth), (function(err, items) {
this.transform(err, items, done)
}).bind(this))
}
/**
* Expose `Strategy`.
*/
module.exports = Strategy;