UNPKG

hapi-cas

Version:

An authorization plugin for Hapi that implements JASIG CAS authentication.

219 lines (200 loc) 9 kB
'use strict' const path = require('path') const CAS = require('simple-cas-interface') const Hoek = require('hoek') const Joi = require('joi') const Boom = require('boom') const dotProp = require('dot-prop') let log = require('abstract-logging') /** * <p>Defines the possible options for the plugin.</p> * * @typedef {object} PluginOptions * @property {string} casServerUrl The URL for the remote CAS server. It * <em>should</em> be an HTTPS URL. But it <em>can</em> be HTTP if the remote * server isn't fully protocol compliant. * Example: <tt>https://example.com/cas/</tt> * @property {number} [casProtocolVersion=2.0] The version of the CAS protocol * that the remote server implements. * @property {string} [casRequestMethod=GET] The HTTP method that the remote * CAS server should use to communicate with the local CAS handler end point. * <strong>NOTE:</strong> only <em>GET</em> is currently supported. * @property {boolean} [casAsGateway=false] Indicates if the remote CAS server * should use its gateway method of operation. * @property {string} localAppUrl The base URL for your local applications. It * <em>should</em> be an HTTPS URL. But it <em>can</em> be HTTP if the remote * server isn't fully protocol compliant. * Example: <tt>https://app.example.com/</tt> * @property {string} endPointPath The URI path where your application will * listen for incoming CAS protocol messages. Example: <tt>/casHandler</tt> * @property {string} [defaultRedirectUrl] If the user bookmarks the remote * CAS server login URL, then a session will not exist to get a redirect * path from. Set this to define a default redirect URL in these cases. * Default: `localAppUrl` value * @property {array} [includeHeaders=['cookie']] The headers to include in * redirections. This list <em>must</em> include the header your session * manager uses for tracking session identifiers. * @property {boolean} [strictSSL=true] Determines if the client will require * valid remote SSL certificates or not. * @property {boolean} [saveRawCAS=false] If true the CAS result will be * saved into session.rawCas * @property {Array} [sessionCredentialsMappings=undefined] An array of objects * where the values of the attribute of <code>request.session</code> listed * in <code>object.sessionAttribute</code> will be mapped to the attribute of * <code>request.auth.credentials</code> listed in * <code>object.credentialsAttribute</code>. For example, if * <code>sessionCredentialsMappings</code> contains * <code>{sessionAttribute: 'foo.bar', credentialsAttribute: 'baz'}</code> * then <code>request.auth.credentials.baz</code> will contain the same data * as <code>request.session.foo.bar</code>. <strong>NOTE</strong>: dot * notation in the <code>sessionAttribute</code> and * <code>credentialsAttribute</code> attributes is supported. * @property {object} [logger=undefined] An instance of a logger that conforms * to the Log4j interface. We recommend {@link https://npm.im/pino} */ const optsSchema = Joi.object().keys({ casServerUrl: Joi.string().uri({scheme: ['http', 'https']}).required(), casProtocolVersion: Joi.number().valid([1, 2, 3]).default(2.0), casRequestMethod: Joi.string().valid(['GET', 'POST']).default('GET'), casAsGateway: Joi.boolean().default(false), localAppUrl: Joi.string().uri({scheme: ['http', 'https']}).required(), endPointPath: Joi.string().regex(/^\/[\w\W/]+\/?$/).required(), defaultRedirectUrl: Joi.string().optional(), includeHeaders: Joi.array().items(Joi.string()).default(['cookie']), strictSSL: Joi.boolean().default(true), saveRawCAS: Joi.boolean().default(false), sessionCredentialsMappings: Joi.array().items(Joi.object().keys({ sessionAttribute: Joi.string(), credentialsAttribute: Joi.string() }).requiredKeys('sessionAttribute', 'credentialsAttribute')).optional(), logger: Joi.object().optional() }) /** * <p>Provides an authentication plugin for the Hapi framework that implements * CAS authentication. Due to the nature of the CAS protocol, this plugin * requires that a session manager plugin be registered with Hapi. This plugin * does not provide a session manager on its own. The 'hapi-server-session' * plugin is known to work. But any plugin that provides * <tt>request.session</tt> will work.</p> * * <p>This plugin is known to work with authentication modes 'required' and * 'try'.</p> * * @param {object} server A Hapi server instance. * @param {PluginOptions} options The options for the CAS authentication plugin. * @returns {object} A Hapi authentication scheme object. * @throws {AssertionError} When an invalid options object is provided or if * there isn't a session manager registered with the Hapi server. */ function casPlugin (server, options) { Hoek.assert(options, 'Missing CAS auth scheme options') const _options = Joi.validate(options, optsSchema) Hoek.assert(!_options.error, 'Options object does not pass schema validation: ' + (_options.error ? _options.error.message : '')) log = (_options.value.logger) ? _options.value.logger.child({module: 'hapi-cas'}) : log log.trace('validated options') const casOptions = { serverUrl: _options.value.casServerUrl, serviceUrl: _options.value.localAppUrl + _options.value.endPointPath, protocolVersion: _options.value.casProtocolVersion, method: _options.value.casRequestMethod, useGateway: _options.value.casAsGateway, strictSSL: _options.value.strictSSL, logger: log } const cas = new CAS(casOptions) function addHeaders (request, response) { if (!response || !response.header || typeof response.header !== 'function') return response for (let h of _options.value.includeHeaders) { response.header(h, request.headers[h]) } return response } function gethandler (request, reply) { const ticket = request.query.ticket if (!ticket) { log.trace('No ticket query parameter supplied to CAS handler end point') const boom = Boom.badRequest('Missing ticket parameter') return addHeaders(request, reply(boom)) } return cas.validateServiceTicket(ticket).then(function (result) { log.trace('Service ticket validated: %j', result) const redirectPath = request.session.requestPath || _options.value.localAppUrl request.session.requestPath = undefined request.session.isAuthenticated = true request.session.username = result.user.toLowerCase() request.session.attributes = result.attributes || {} // Save raw cas result for processing by client if (_options.value.saveRawCAS) { request.session.rawCas = result } return addHeaders(request, reply(result)).redirect(redirectPath) }) .catch(function caught (error) { log.error('Service ticket validation failed: %s', error.message) log.debug(error.stack) return addHeaders(request, reply(Boom.forbidden(error.message))) }) } server.route({ method: 'GET', path: options.endPointPath, handler: gethandler, config: { auth: false, cache: { privacy: 'private', expiresIn: 0 } } }) const scheme = {} scheme.authenticate = function casAuth (request, reply) { const session = request.session if (!session) { log.trace('No session provider registered!') return reply(Boom.notImplemented( 'hapi-cas requires a registered Hapi session provider' )) } const credentials = { username: session.username, attributes: session.attributes } if (_options.value.sessionCredentialsMappings) { for (let i = 0; i < _options.value.sessionCredentialsMappings.length; i++) { dotProp.set(credentials, _options.value.sessionCredentialsMappings[i].credentialsAttribute, dotProp.get(session, _options.value.sessionCredentialsMappings[i].sessionAttribute)) } } log.trace('Credentials: %j', credentials) if (session.isAuthenticated) { log.trace('User authenticated by session lookup') return reply.continue({credentials: credentials}) } log.trace('Redirecting auth to: %s', cas.loginUrl) session.requestPath = request.path return addHeaders( request, reply('cas redirect', null, {credentials: credentials}) ) .redirect(cas.loginUrl) } return scheme } /** * Standard Hapi plugin registration method. It registers {@link casPlugin} * with the scheme name 'cas'. * * @param {object} server A Hapi server instance. * @param {object} options A Hapi plugin registration options object. * @param {function} next The Hapi registration finished callback function. * @returns {function} The registration finished callback function. */ exports.register = function (server, options, next) { server.auth.scheme('cas', casPlugin) return next() } module.exports.register.attributes = { pkg: require(path.join(__dirname, 'package.json')) }