passport-cas2
Version:
CAS 2.0 strategy for Passport.js authentication
371 lines (335 loc) • 11.6 kB
JavaScript
/**
* Module dependencies.
*/
var url = require('url')
, util = require('util')
, Strategy = require('passport-strategy')
, CAS = require('cas');
/**
* Creates an instance of `CasStrategy`.
*
* CAS stands for Central Authentication Service, and is a single sign-on
* solution for the web.
*
* Authentication is done by redirecting the user to the CAS login page. The
* user will return with a ticket in the querystring. This ticket is then
* validated by the application against the CAS server to obtain the username
* and profile.
*
* (CAS optionally allows the application to obtain tickets for 3rd party
* services on behalf of the user. This requires the use of a PGT callback
* server, which can be run with the PgtServer() function also from this
* module.)
*
* Applications must supply a `verify` callback, for which the function
* signature is:
*
* function(username, profile, done) { ... }
*
* The verify callback is responsible for finding or creating the user, and
* invoking `done` with the following arguments:
*
* done(err, user, info);
*
* `user` should be set to `false` to indicate an authentication failure.
* Additional `info` can optionally be passed as a third argument, typically
* used to display informational messages. If an exception occured, `err`
* should be set.
*
* Options:
*
* - `casURL` URL of the CAS server (e.g. https://signin.example.com/cas)
* - `pgtURL` Optional. URL of the PGT callback server (e.g. https://callback.example.com)
* - `sessionKey` Optional. The name to use for storing CAS information within the `req.session` object. Default is 'cas'.
* - `propertyMap` Optional. A basic key-value object for mapping extended user attributes from CAS to passport's profile format.
* - `passReqToCallback` Optional. When `true`, `req` is the first argument to the verify callback (default: `false`)
* - `sslCA` Optional. SSL CA bundle to use to validate the PGT server.
*
* Example:
*
* var CasStrategy = require('passport-cas2').Strategy;
* var cas = new CasStrategy({
* casURL: 'https://signin.example.com/cas',
* propertyMap: {
* id: 'guid',
* givenName: 'givenname',
* familyName: 'surname',
* emails: 'defaultmail'
* }
* },
* function(username, profile, done) {
* User.findOrCreate(..., function(err, user) {
* done(err, user);
* });
* });
* passport.use(cas);
*
* @constructor
* @param {Object} options
* @param {Function} verify
* @api public
*/
function CasStrategy(options, verify) {
if (typeof options == 'function') {
verify = options;
options = undefined;
}
options = options || {};
if (!verify) { throw new TypeError('CasStrategy requires a verify callback'); }
if (!options.casURL) { throw new TypeError('CasStrategy requires a casURL option'); }
Strategy.call(this);
this.name = 'cas';
this._verify = verify;
this._passReqToCallback = options.passReqToCallback;
this.casBaseUrl = options.casURL;
this.casPgtUrl = options.pgtURL || undefined;
this.casPropertyMap = options.propertyMap || {};
this.casSessionKey = options.sessionKey || 'cas';
this.cas = new CAS({
base_url: this.casBaseUrl,
version: 2,
external_pgt_url: this.casPgtUrl,
ssl_cert: options.sslCert,
ssl_key: options.sslKey,
ssl_ca: options.sslCA
});
}
/**
* Inherit from `Strategy`.
*/
util.inherits(CasStrategy, Strategy);
/**
* Authenticate request by validating a ticket with the CAS server.
*
* @param {Object} req
* @param {Object} options
* @api protected
*/
CasStrategy.prototype.authenticate = function(req, options) {
if (!req._passport) { return this.error(new Error('passport.initialize() middleware not in use')); }
options = options || {};
var self = this;
var reqURL = url.parse(req.originalUrl || req.url, true);
var service;
// `ticket` is present if user is already authenticated/authorized by CAS
var ticket = reqURL.query['ticket'];
// The `service` string is the current URL, minus the ticket
delete reqURL.query['ticket'];
service = url.format({
protocol: req.headers['x-forwarded-proto'] || req.headers['x-proxied-protocol'] || req.protocol || 'http',
host: req.headers['x-forwarded-host'] || req.headers.host || reqURL.host,
pathname: req.headers['x-proxied-request-uri'] || reqURL.pathname,
query: reqURL.query
});
if (!ticket) {
// Redirect to CAS server for authentication
self.redirect(self.casBaseUrl + '/login?service=' + encodeURIComponent(service), 307);
}
else {
// User has returned from CAS site with a ticket
self.cas.validate(ticket, function(err, status, username, extended) {
// Ticket validation failed
if (err) {
var date = new Date();
var token = Math.round(date.getTime() / 60000);
if (req.query['_cas_retry'] != token) {
// There was a CAS error. A common cause is when an old
// `ticket` portion of the querystring remains after the
// session times out and the user refreshes the page.
// So remove the `ticket` and try again.
var url = (req.originalUrl || req.url)
.replace(/_cas_retry=\d+&?/, '')
.replace(/([?&])ticket=[\w.-]+/, '$1_cas_retry='+token);
self.redirect(url, 307);
} else {
// Already retried. There is no way to recover from this.
self.fail(err);
}
}
// Validation successful
else {
// The provided `verify` callback will call this on completion
function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(info); }
self.success(user, info);
}
req.session[self.casSessionKey] = {};
if (self.casPgtUrl) {
req.session[self.casSessionKey].PGTIOU = extended.PGTIOU;
}
var attributes = extended.attributes;
var profile = {
provider: 'CAS',
id: extended.id || username,
displayName: attributes.displayName || username,
name: {
familyName: null,
givenName: null,
middleName: null
},
emails: []
};
// Map relevant extended attributes returned by CAS into the profile
for (var key in profile) {
if (key == 'name') {
for (var subKey in profile[key]) {
var mappedKey = self.casPropertyMap[subKey] || subKey;
var value = attributes[mappedKey];
if (Array.isArray(value)) {
profile.name[subKey] = value[0];
} else {
profile.name[subKey] = value;
}
delete attributes[mappedKey];
}
}
else if (key == 'emails') {
var mappedKey = self.casPropertyMap.emails || 'emails';
var emails = attributes[mappedKey];
if (Array.isArray(emails)) {
if (typeof emails[0] == 'object') {
profile.emails = emails;
}
else {
for (var i=0; i<emails.length; i++) {
profile.emails.push({
'value': emails[i],
'type': 'default'
});
}
}
}
else {
profile.emails = [emails];
}
delete attributes[mappedKey];
}
else {
var mappedKey = self.casPropertyMap[key] || key;
var value = attributes[mappedKey];
if (Array.isArray(value)) {
profile[key] = value[0];
}
else if (value) {
profile[key] = value;
}
delete attributes[mappedKey];
}
}
// Add remaining attributes to the profile object
for (var key in attributes) {
profile[key] = attributes[key];
}
if (self._passReqToCallback) {
self._verify(req, username, profile, verified);
} else {
self._verify(username, profile, verified);
}
}
}, service);
}
};
/**
* Log the user out of the application site, and also out of CAS.
*
* @param (Object) req
* @param (Object) res
* @param {String} returnUrl
* @api public
*/
CasStrategy.prototype.logout = function(req, res, returnUrl) {
req.logout();
if (returnUrl) {
this.cas.logout(req, res, returnUrl, true);
} else {
this.cas.logout(req, res);
}
};
/**
* Request a CAS ticket for accessing a service on behalf of the logged in user.
* This ticket is to be added to the service's query string.
*
* Example:
*
* var serviceURL = 'http://example.com/get/my/data';
* cas.getProxyTicket(req, serviceURL, function(err, ticket) {
* if (!err) {
* serviceURL += '?ticket=' + ticket;
* request(serviceURL, ... ); // request the service
* }
* });
*
* @param {Object} req
* HTTPRequest object from Connect/Express
* @param {String} targetService
* The URL of service being requested on behalf of the user
* @param {Function} done
* Completion callback with signature `fn(err, ticket)`
* @api public
*/
CasStrategy.prototype.getProxyTicket = function(req, targetService, done) {
var err, pgtiou;
if (!req.session) {
err = new Error('Session is not found');
}
else if (!req.session[this.casSessionKey]) {
err = new Error('User is not authenticated with CAS');
}
else {
pgtiou = req.session[this.casSessionKey].PGTIOU;
if (!pgtiou) {
err = new Error('PGTIOU token not found. Make sure pgtURL option is correct, and the CAS server allows proxies.');
}
}
if (err) {
return done(err);
}
else {
this.cas.getProxyTicket(pgtiou, targetService, function(err, PT) {
done(err, PT);
});
}
}
/**
* Expose `CasStrategy`.
*/
module.exports.Strategy = CasStrategy;
/**
* Start a CAS PGT callback server. PGT stands for proxy granting ticket.
*
* This is the server needed to obtain CAS tickets for 3rd party services on
* behalf of the user. It is typically run as a separate process from the
* application. Multiple applications may share the same PGT callback server.
*
* @param {String} casURL
* The URL of the CAS server.
* @param {String} pgtURL
* The URL of this PGT callback server. It must use HTTPS and be accessible
* by the CAS server over the network.
* The 3rd party services you request may need to whitelist this URL.
* @param {String} serverCertificate
* The SSL certificate for this PGT callback server.
* @param {String} serverKey
* The key for the SSL certificate.
* @param {Array} serverCA
* Optional array of SSL CA and intermediate CA certificates
* @api public
*/
function PgtServer(casURL, pgtURL, serverCertificate, serverKey, serverCA) {
var parsedURL = url.parse(pgtURL);
var cas = new CAS({
base_url: casURL,
version: 2.0,
pgt_server: true,
pgt_host: parsedURL.hostname,
pgt_port: parsedURL.port,
ssl_key: serverKey,
ssl_cert: serverCertificate,
ssl_ca: serverCA || null
});
}
/**
* Expose `PgtServer`.
*/
module.exports.PgtServer = PgtServer;