├── .covignore ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── index.js ├── lib ├── acl.js ├── auth.js ├── index.js ├── roles.js └── schema.js ├── package.json └── test ├── auth.js └── index.js /.covignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asafdav/hapi-auth-extra/f213c062e3295f966fd174fdb8c585a79185159d/.covignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | npm-debug.log 3 | .idea 4 | lib-cov 5 | coverage.html -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Asaf David 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @node node_modules/mocha/bin/mocha 3 | 4 | test-cov: clean lib-cov 5 | @AUTH_EXTRA_COV=1 node_modules/mocha/bin/mocha -R html-cov > coverage.html 6 | 7 | clean: 8 | @rm -rf ./lib-cov \ 9 | @rm -f coverage.html 10 | 11 | lib-cov: 12 | @node node_modules/jscoverage/bin/jscoverage lib lib-cov 13 | 14 | .PHONY: test test-cov 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hapi-auth-extra 2 | =============== 3 | 4 | [![Build Status](https://travis-ci.org/asafdav/hapi-auth-extra.svg?branch=master)](https://travis-ci.org/asafdav/hapi-auth-extra) 5 | 6 | Additional authentication toolbox for HapiJS. 7 | 8 | It includes: 9 | * ACL support 10 | * Authentication strategy for APIs (Token based) 11 | 12 | 13 | ### Support 14 | Hapi = 9.* 15 | 16 | How to use it: 17 | -------------- 18 | 19 | ### Token authentication 20 | 21 | This plugin provides an easy way to implement token based authentication, it could be a good solution for internal APIs, for external APIs please consider using oAuth instead. 22 | All you have to do is to provide a method that validates a token and returns the related user in case the token is valid. In order to use this feature, 23 | you need to register the plugin and enable 'auth-token' authentication schema that the plugin provides. 24 | 25 | Example: 26 | ```javascript 27 | // Sample token validator, you may replace with your own implementation. 28 | function validateToken(token, cb) { 29 | return cb(null, {_id: '123', name: 'Test User'}); // Returns a sample user, this is the authenticated user. 30 | } 31 | 32 | var server = Hapi.createServer(0); 33 | // Register the plugin 34 | server.register('hapi-auth-extra', { 35 | tokenAuth: { 36 | tokenValidator: validateToken // Set the custom validator 37 | } 38 | }, function(err) { 39 | 40 | server.route({ method: 'GET', path: '/', config: { 41 | auth: 'default', // Protect this route 42 | handler: function (request, reply) { reply("Authorized");} 43 | }}); 44 | 45 | // Load the authentication schema 46 | server.auth.strategy('default', 'auth-token'); 47 | }); 48 | ``` 49 | 50 | 51 | ### ACL 52 | You can use this plugin to add ACL and protect your routes. you can configure required roles and allow access to certain endpoints only to specific users. 53 | 54 | In order to activate the plugin for a specific route, all you have to do is to add hapiAuthExtra instructions to the route configuration, for example: 55 | 56 | ```javascript 57 | server.route({ method: 'GET', path: '/', config: { 58 | auth: 'default', 59 | plugins: {'hapiAuthExtra': {role: 'ADMIN'}}, 60 | handler: function (request, reply) { reply("Great!");} 61 | }}); 62 | ``` 63 | 64 | **Note:** every route that uses hapiAuthExtra must be protected by an authentication schema (auth: true). 65 | 66 | #### Examples 67 | 68 | * Protected by role 69 | You can protect a route and set a role that is required for executing it. 70 | The following example makes sure that only admins will be able to create new products. 71 | 72 | ```javascript 73 | server.route({ method: 'POST', path: '/product', config: { 74 | auth: 'default', // Protected route 75 | plugins: {'hapiAuthExtra': {role: 'ADMIN'}}, // Only admin 76 | handler: function (request, reply) { reply({title: 'New product'}).code(201);} 77 | }}); 78 | ``` 79 | 80 | * Default entity ACL 81 | You can protect a route and allow only the entitiy's creator to modify it. 82 | The following example makes sure that only the video owner will be able to delete it. 83 | 84 | ```javascript 85 | server.route({ method: 'DELETE', path: '/video/{id}', config: { 86 | auth: 'default', // Protected route 87 | plugins: {'hapiAuthExtra': { 88 | validateEntityAcl: true, // Validate the entity ACL 89 | aclQuery: function(id, cb) { // This query is used to fetch the entitiy, by default auth-extra will verify the field _user. 90 | cb(null, {_user: '1', name: 'Hello'}); // You can use and method you want as long as you keep this signature. 91 | } 92 | }}, 93 | handler: function (request, reply) { reply("Authorized");} 94 | }}); 95 | ``` 96 | 97 | * Custom ACL 98 | TBD 99 | 100 | Full list of supported parameters: 101 | -------------------- 102 | * role - String: enforces that only users that has this role can access the route 103 | * aclQuery - Function: fetches an entity using the provided query, it allows the plugin to verify that the authenticated user has permissions to access this entity. the function signature should be function(parameter, cb). 104 | * aclQueryParam: String: The parameter key that will be used to fetch the entity. default: 'id' 105 | * paramSource: String: The source of the acl parameter, allowed values: payload, params, query. 106 | * validateEntityAcl: Boolean: Should the plugin validate if the user has access to the entity. if true, validateAclMethod is required. 107 | * validateAclMethod: String: A function name. the plugin will invoke this method on the provided entity and will use it to verify that the user has permissions to access this entity. function signature is function(user, role, cb); 108 | 109 | 110 | ### TODO 111 | * Write an example (For now, see the tests for more information) 112 | * Add output filtering 113 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); -------------------------------------------------------------------------------- /lib/acl.js: -------------------------------------------------------------------------------- 1 | // External modules 2 | var Boom = require('boom'); 3 | var Q = require('q'); 4 | 5 | // Internal modules 6 | var RoleHierarchy = require('./roles').hierarchy; 7 | 8 | // Declare of internals 9 | var internals = {}; 10 | 11 | 12 | /** 13 | * Checks if the user has the wanted roles 14 | * 15 | * @param user 16 | * @param role 17 | * @returns {*} 18 | */ 19 | exports.checkRoles = function (user, role) { 20 | if (!internals.isGranted(user.role, role)) return Boom.unauthorized('Unauthorized'); 21 | return null; 22 | }; 23 | 24 | /** 25 | * Checks if the provided user role is included is the wanted role or is included in the wanted role hierarchy 26 | * @param userRole 27 | * @param requiredRole 28 | * @returns {boolean} 29 | */ 30 | internals.isGranted = function (userRole, requiredRole) { 31 | var userRoles = RoleHierarchy[userRole]; 32 | return (userRoles.indexOf(requiredRole) > -1); 33 | }; 34 | 35 | /** 36 | * Fetches the wanted acl entity using the provided 37 | * @param query - function(id, cb) that returns the entity to the callback. 38 | * @param param - The "id" parameter that need to be provided for to the query 39 | * @param request - The originating request 40 | * @param cb - function(err, entity) that will be used to notify the caller about the result of the query 41 | */ 42 | exports.fetchEntity = function (query, param, request, cb) { 43 | var def = Q.defer(); 44 | query(param, function (err, entity) { 45 | if (err) return def.reject(Boom.internal('Bad request', err)); 46 | else if (!entity) return def.reject(Boom.notFound()); 47 | else def.resolve(entity); 48 | }); 49 | 50 | return def.promise; 51 | }; 52 | 53 | /** 54 | * Verifies that the user has permission to access the wanted entity. 55 | * 56 | * @param user - The authenticated user 57 | * @param role - The wanted role, undefined means any role 58 | * @param entity - Verify if the authenticated user has "role" grants and can access this entity 59 | * @param validator - The method that will be used to verify if the user has permissions, this method should be used on the provided entity. 60 | * @param options - additional options 61 | * @returns {promise|*|Q.promise} 62 | */ 63 | exports.validateEntityAcl = function (user, role, entity, validator, options) { 64 | var def = Q.defer(); 65 | 66 | if (!entity) def.reject(new Error('validateUserACL must run after fetchACLEntity')); 67 | else if (!user) def.reject(new Error('User is required, please make sure this method requires authentication')); 68 | else { 69 | if (validator) { 70 | entity[validator](user, role, function (err, isValid) { 71 | if (err) def.reject(new Error(err)); 72 | 73 | // Not granted 74 | else if (!isValid) def.reject(Boom.unauthorized('Unauthorized')); 75 | 76 | // Valid 77 | else def.resolve(isValid); 78 | }); 79 | } else { 80 | // Use the default validator 81 | var isValid = internals.defaultEntityAclValidator(user, role, entity, options); 82 | 83 | if (!isValid) def.reject(Boom.unauthorized('Unauthorized')); 84 | else def.resolve(isValid); 85 | } 86 | } 87 | 88 | return def.promise; 89 | }; 90 | 91 | /** 92 | * Default validator 93 | * 94 | * @param user 95 | * @param role 96 | * @param entity 97 | * @returns {*|string|boolean} 98 | */ 99 | internals.defaultEntityAclValidator = function (user, role, entity, options) { 100 | return ( 101 | entity[options.entityUserField] && 102 | user[options.userIdField] && 103 | entity[options.entityUserField].toString() === user[options.userIdField].toString() 104 | ); 105 | }; -------------------------------------------------------------------------------- /lib/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by asafdav on 3/11/14. 3 | */ 4 | var Hoek = require('hoek'); 5 | var Boom = require('boom'); 6 | 7 | var internals = {}; 8 | var defaults = { 9 | tokenSelector: null, 10 | tokenValidator: null 11 | }; 12 | 13 | exports.register = function(server, options) { 14 | server.auth.scheme('auth-token', internals.authSchema(options)); 15 | }; 16 | 17 | 18 | /** 19 | * Implements the authentication schema, 20 | * 21 | * @param options - A settings object that are needed for the authentication process, the allowed values are: 22 | * - tokenSelector - (optional) A synchronous function that extracts the token from the request. the default behaviour is to look for the Authorization header. 23 | * - tokenValidator - A function that validates if the token is valid and belongs to an active user, the function signature should be function(token, cb) 24 | * the cb signature is function(err,user) 25 | * 26 | * @param options 27 | * @returns {Function} 28 | */ 29 | internals.authSchema = function(options) { 30 | 31 | var settings = Hoek.applyToDefaults(defaults, options || {}); 32 | Hoek.assert(!!settings.tokenValidator, 'tokenValidator is required'); 33 | 34 | return function (server, funcOptions) { 35 | var scheme = { 36 | authenticate: function (request, reply) { 37 | // Find the token 38 | var token = (settings.tokenSelector ? settings.tokenSelector : internals.getAuthToken)(request); 39 | if (!token) return reply(Boom.unauthorized('Can\'t authenticate the request')); 40 | 41 | // Check if the token is valid 42 | settings.tokenValidator(token, function(err, user) { 43 | if (err) return reply(Boom.internal(err)); 44 | if (!user) return reply(Boom.unauthorized("Unknown user")); 45 | 46 | // The user is valid, return it 47 | return reply.continue({credentials: user}); 48 | }); 49 | } 50 | }; 51 | 52 | return scheme; 53 | }; 54 | }; 55 | 56 | /** 57 | * Default token selector implementation, 58 | * Fetches the token from the Authrization header, for example for "Authorization: Bearer 1" the function will return 1 59 | * If no authorization header is presented or it is in a bad format, a null will be returned. 60 | * 61 | * @param request 62 | * @returns {*} 63 | */ 64 | internals.getAuthToken = function(request) { 65 | if (request.headers && request.headers.authorization) { 66 | var parts = request.headers.authorization.split(' '); 67 | if (parts.length == 2) return parts[1]; 68 | } 69 | 70 | return null; 71 | }; 72 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var Hoek = require('hoek'); 2 | var Boom = require('boom'); 3 | var Q = require('q'); 4 | 5 | var Roles = require('./roles'); 6 | var Schema = require('./schema'); 7 | var ACL = require('./acl'); 8 | var Auth = require('./auth'); 9 | 10 | var pluginName = 'hapiAuthExtra'; 11 | var internals = { 12 | defaults: { 13 | roles : Roles.roles, 14 | tokenAuth: false 15 | } 16 | }; 17 | 18 | /** 19 | * Registers the plugin 20 | * @param server 21 | * @param options 22 | * @param next 23 | */ 24 | exports.register = function (server, options, next) { 25 | try { 26 | var settings = Hoek.applyToDefaults(internals.defaults, options || {}); 27 | 28 | server.bind({ 29 | config: settings 30 | }); 31 | 32 | server.after(internals.validateRoutes); 33 | server.ext('onPostAuth', internals.onPostAuth); 34 | 35 | if (settings.tokenAuth) Auth.register(server, settings.tokenAuth); 36 | 37 | return next(); 38 | } catch (e) { 39 | return next(e); 40 | } 41 | }; 42 | 43 | /** 44 | * Backward compatibility 45 | * @type {{pkg: exports}} 46 | */ 47 | exports.register.attributes = { 48 | pkg: require('../package.json') 49 | }; 50 | 51 | /** 52 | * Backward compatibility 53 | * @type {{pkg: exports}} 54 | */ 55 | exports.register.attributes = { 56 | pkg: require('../package.json') 57 | }; 58 | 59 | /** 60 | * Runs on server start and validates that every route that has extra-auth-params is valid 61 | * @param server 62 | * @param next 63 | */ 64 | internals.validateRoutes = function (server, next) { 65 | try { 66 | server.table().forEach(function (routingTable) { 67 | routingTable.table.forEach(function (table) { 68 | var extraAuthParams = table.settings.plugins[pluginName] ? table.settings.plugins[pluginName] : false; 69 | if (!!extraAuthParams) { 70 | Hoek.assert(!!table.settings.auth && table.settings.auth !== null, 'extra-auth can be enabled only for secured route'); 71 | Schema.assert('route', extraAuthParams, 'Invalid settings'); 72 | } 73 | }); 74 | }); 75 | next(); 76 | } catch (err) { 77 | next(err); 78 | } 79 | }; 80 | 81 | /** 82 | * Checks if auth-extra is active for the current route and execute the necessary steps accordingly. 83 | * @param request 84 | * @param reply 85 | */ 86 | internals.onPostAuth = function (request, reply) { 87 | try { 88 | // Check if the current route is auth-extra enabled 89 | var params = internals.getRouteParams(request); 90 | if (params) { 91 | // auth-extra is enabled, get the user 92 | var user = request.auth.credentials; 93 | if (!request.plugins[pluginName]) request.plugins[pluginName] = {}; 94 | 95 | Q 96 | // Checks roles 97 | .fcall(function () { 98 | if (params.role && !params.validateUserAcl) { 99 | var err = ACL.checkRoles(user, params.role); 100 | if (err) throw err; 101 | } 102 | return true; 103 | }) 104 | // Fetches acl entities 105 | .then(function () { 106 | if (params.aclQuery) { 107 | var parameter = request[params.paramSource][params.aclQueryParam]; 108 | return ACL.fetchEntity(params.aclQuery, parameter, request); 109 | } 110 | return null; 111 | }) 112 | // Store the entity 113 | .then(function (entity) { 114 | if (entity) { 115 | request.plugins[pluginName].entity = entity; 116 | return entity; 117 | } 118 | }) 119 | // Validate the ACL settings 120 | .then(function (entity) { 121 | if (params.validateEntityAcl) { 122 | if (!entity) throw new Error('Entity is required'); 123 | 124 | return ACL.validateEntityAcl(user, params.role, entity, params['validateAclMethod'], params); 125 | } 126 | return null; 127 | }) 128 | .then(function () { 129 | reply.continue(); 130 | }) 131 | // Handles errors 132 | .catch(function (err) { 133 | reply(err); 134 | }); 135 | 136 | } else { 137 | reply.continue(); 138 | } 139 | } 140 | catch (err) { 141 | //console.log(err); 142 | reply(Boom.badRequest(err.message)); 143 | } 144 | }; 145 | 146 | /** 147 | * Returns the plugin params for the current request 148 | * @param request 149 | * @returns {*} 150 | */ 151 | internals.getRouteParams = function (request) { 152 | if (!!request.route.settings.plugins[pluginName]) { 153 | var params = request.route.settings.plugins[pluginName]; 154 | return Schema.assert('route', params, 'Invalid settings'); 155 | } else { 156 | return null; 157 | } 158 | }; -------------------------------------------------------------------------------- /lib/roles.js: -------------------------------------------------------------------------------- 1 | // TODO - generalize 2 | 3 | var RoleTypes = { 4 | SUPER_ADMIN: 'SUPER_ADMIN', 5 | ADMIN: 'ADMIN', 6 | USER: 'USER', 7 | GUEST: 'GUEST' 8 | }; 9 | 10 | 11 | var RoleHierarchy = {}; 12 | RoleHierarchy[RoleTypes.SUPER_ADMIN] = [RoleTypes.SUPER_ADMIN, RoleTypes.ADMIN, RoleTypes.USER, RoleTypes.GUEST]; 13 | RoleHierarchy[RoleTypes.ADMIN] = [RoleTypes.ADMIN, RoleTypes.USER, RoleTypes.GUEST]; 14 | RoleHierarchy[RoleTypes.USER] = [RoleTypes.USER, RoleTypes.GUEST]; 15 | RoleHierarchy[RoleTypes.GUEST] = [RoleTypes.GUEST]; 16 | 17 | module.exports = { 18 | roles: RoleTypes, 19 | hierarchy: RoleHierarchy 20 | }; -------------------------------------------------------------------------------- /lib/schema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by asafdav on 18/05/14. 3 | */ 4 | var Joi = require('joi'); 5 | var Hoek = require('hoek'); 6 | 7 | // Internals 8 | var internals = {}; 9 | 10 | exports.assert = function (type, options, message) { 11 | 12 | var validationObj = Joi.validate(options, internals[type]); 13 | var error = validationObj.error; 14 | Hoek.assert(!error, 'Invalid', type, 'options', message ? '(' + message + ')' : '', error && error.annotate()); 15 | return validationObj.value; 16 | }; 17 | 18 | 19 | internals.route = Joi.object({ 20 | role: Joi.string(), 21 | aclQuery: Joi.func().when('validateEntityAcl', {is: true, then: Joi.required()}), 22 | aclQueryParam: Joi.string().default('id'), 23 | paramSource: Joi.string().allow('payload', 'params', 'query').default('params'), 24 | validateEntityAcl: Joi.boolean().default(false), 25 | validateAclMethod: Joi.string().default(null), 26 | entityUserField: Joi.string().default("_user"), 27 | entityRoleField: Joi.string().default("role"), 28 | userIdField: Joi.string().default("_id"), 29 | userRoleField: Joi.string().default("role") 30 | }).options({ allowUnknown: false }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-auth-extra", 3 | "version": "0.0.9", 4 | "description": "Additional auth toolbox for HapiJS including ACL support", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "make test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:asafdav/hapi-auth-extra.git" 12 | }, 13 | "keywords": [ 14 | "hapi", 15 | "auth", 16 | "acl" 17 | ], 18 | "author": { 19 | "name": "Asaf David", 20 | "email": "asafdav@gmail.com", 21 | "url": "http://about.me/asafdavid" 22 | }, 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/asafdav/hapi-auth-extra/issues" 26 | }, 27 | "homepage": "https://github.com/asafdav/hapi-auth-extra", 28 | "dependencies": { 29 | "boom": "^2.8.0", 30 | "hoek": "^2.14.0", 31 | "joi": "^6.6.1", 32 | "q": "^1.4.1" 33 | }, 34 | "devDependencies": { 35 | "chai": "^3.2.0", 36 | "hapi": "^9.0.1", 37 | "jscoverage": "^0.6.0", 38 | "mocha": "^2.2.5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by asafdav on 21/05/14. 3 | */ 4 | 5 | // External modules 6 | var Chai = require('chai'); 7 | var Hapi = require('hapi'); 8 | 9 | // Internal modules 10 | var libpath = process.env['AUTH_EXTRA_COV'] ? '../lib-cov' : '../lib'; 11 | var Plugin = require(libpath + '/index'); 12 | var PluginObject = { 13 | name : 'hapiAuthExtra', 14 | version : '0.0.0', 15 | register: Plugin.register, 16 | path : libpath 17 | }; 18 | 19 | // Declare internals 20 | var internals = {}; 21 | 22 | // Test shortcuts 23 | var expect = Chai.expect; 24 | 25 | describe('AuthTokenSchema', function () { 26 | describe('initialize', function () { 27 | it('validated the presence of tokenValidator', function (done) { 28 | var server = new Hapi.Server(); 29 | server.connection(); 30 | server.register({register: Plugin, options: {tokenAuth: true}}, 31 | function (err) { 32 | expect(err).to.be.defined; 33 | expect(err).to.match(/tokenValidator is required/); 34 | done(); 35 | }); 36 | }); 37 | 38 | it('auth-token strategy is not available when tokenAuth is false', function (done) { 39 | var server = new Hapi.Server(0); 40 | server.connection(); 41 | server.register({register: PluginObject, options: {tokenAuth: false}}, 42 | function (err) { 43 | expect(function () { 44 | server.auth.strategy('default', 'auth-token'); 45 | }).to.throw(/unknown scheme: auth-token/); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('auth-token strategy is available when tokenAuth is enabled', function (done) { 51 | var server = new Hapi.Server(0); 52 | server.connection(); 53 | server.register({register: PluginObject, options: {tokenAuth: {tokenValidator: function () {}}}}, 54 | function (err) { 55 | expect(function () {server.auth.strategy('default', 'auth-token');}).to.not.throw(); 56 | done(); 57 | }); 58 | }); 59 | }); 60 | 61 | describe('#authenticate', function () { 62 | it('returns error when a token is not found', function (done) { 63 | var server = new Hapi.Server(0); 64 | server.connection(); 65 | server.register({register: PluginObject, options: {tokenAuth: {tokenValidator: function (token, cb) {cb(null, null)}}}}, 66 | function (err) { 67 | server.auth.strategy('default', 'auth-token'); 68 | internals.routes(server); 69 | 70 | server.inject('/', function (res) { 71 | internals.asyncCheck(function () { 72 | expect(res.statusCode).equals(401); 73 | expect(res.result.message).equals("Can't authenticate the request"); 74 | }, done) 75 | }); 76 | }); 77 | }); 78 | 79 | it('returns error when a token validator fails', function (done) { 80 | var server = new Hapi.Server(0); 81 | server.connection(); 82 | server.register({ 83 | register: PluginObject, options: { 84 | tokenAuth: { 85 | tokenValidator: function (token, cb) {cb("BLA", null)}, 86 | tokenSelector : function (request) {return "1"} 87 | } 88 | } 89 | }, function (err) { 90 | server.auth.strategy('default', 'auth-token'); 91 | internals.routes(server); 92 | 93 | server.inject('/', function (res) { 94 | internals.asyncCheck(function () { 95 | expect(res.statusCode).equals(500); 96 | expect(res.result.message).equals("An internal server error occurred"); 97 | }, done) 98 | }); 99 | }); 100 | }); 101 | 102 | it('returns an error when a no user was found', function (done) { 103 | var server = new Hapi.Server(0); 104 | server.connection(); 105 | server.register({ 106 | register: PluginObject, options: { 107 | tokenAuth: { 108 | tokenValidator: function (token, cb) {cb(null, null)}, 109 | tokenSelector : function (request) {return "1"} 110 | } 111 | } 112 | }, function (err) { 113 | server.auth.strategy('default', 'auth-token'); 114 | internals.routes(server); 115 | 116 | server.inject('/', function (res) { 117 | internals.asyncCheck(function () { 118 | expect(res.statusCode).equals(401); 119 | expect(res.result.message).equals("Unknown user"); 120 | }, done) 121 | }); 122 | }); 123 | }); 124 | 125 | it('returns the response for valid requests', function (done) { 126 | var server = new Hapi.Server(0); 127 | server.connection(); 128 | server.register({ 129 | register: PluginObject, options: { 130 | tokenAuth: { 131 | tokenValidator: function (token, cb) {cb(null, {name: 'Asaf'})}, 132 | tokenSelector : function (request) {return "1"} 133 | } 134 | } 135 | }, function (err) { 136 | server.auth.strategy('default', 'auth-token'); 137 | internals.routes(server); 138 | 139 | server.inject('/', function (res) { 140 | internals.asyncCheck(function () { 141 | expect(res.statusCode).equals(200); 142 | expect(res.payload).equals("Authorized"); 143 | }, done) 144 | }); 145 | }); 146 | }); 147 | }); 148 | }); 149 | 150 | internals.routes = function (server) { 151 | server.route({ 152 | method: 'GET', path: '/', config: { 153 | auth : 'default', 154 | handler: function (request, reply) { reply("Authorized");} 155 | } 156 | }); 157 | }; 158 | 159 | internals.asyncCheck = function (f, done) { 160 | try { 161 | f(); 162 | done(); 163 | } catch (e) { 164 | done(e); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by asafdav on 17/05/14. 3 | */ 4 | 5 | // External modules 6 | var Chai = require('chai'); 7 | var Hapi = require('hapi'); 8 | 9 | // Internal modules 10 | var libpath = process.env['AUTH_EXTRA_COV'] ? '../lib-cov' : '../lib'; 11 | var Plugin = require(libpath + '/index'); 12 | 13 | // Declare internals 14 | var internals = {}; 15 | 16 | // Test shortcuts 17 | var expect = Chai.expect; 18 | 19 | describe('hapiAuthExtra', function () { 20 | 21 | describe('Initialize', function () { 22 | 23 | it('makes sure that extra-auth can be enabled only for secured routes', function (done) { 24 | var server = new Hapi.Server(); 25 | server.connection(); 26 | server.route({ 27 | method: 'GET', path: '/', config: { 28 | plugins: {'hapiAuthExtra': {role: 'USER'}}, 29 | handler: function (request, reply) { reply("TEST");} 30 | } 31 | }); 32 | server.register(Plugin, function (err) { 33 | server.start(function (err) { 34 | expect(err).to.not.be.undefined; 35 | expect(err).to.match(/extra-auth can be enabled only for secured route/); 36 | server.stop(function () {}); // Make sure the server is stopped 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | it('Validates the extra-auth routes parameters', function (done) { 43 | var server = new Hapi.Server(0); 44 | server.connection(); 45 | server.auth.scheme('custom', internals.authSchema); 46 | server.auth.strategy('default', 'custom', true, {}); 47 | server.route({ 48 | method: 'GET', path: '/', config: { 49 | auth : 'default', 50 | plugins: {'hapiAuthExtra': {bla: 'USER'}}, 51 | handler: function (request, reply) { reply("TEST");} 52 | } 53 | }); 54 | server.register(Plugin, function (err) { 55 | //expect(server.start).to.throw(/extra-auth can be enabled only for secured route/); 56 | server.start(function (err) { 57 | expect(err).to.not.be.undefined; 58 | expect(err).to.match(/"bla" is not allowed/); 59 | server.stop(function () {}); // Make sure the server is stopped 60 | done(); 61 | }); 62 | }); 63 | }); 64 | 65 | it('ignores routes without extra auth instructions', function (done) { 66 | var server = new Hapi.Server(); 67 | server.connection(); 68 | server.route({ 69 | method : 'GET', 70 | path : '/', 71 | handler: function (request, reply) { 72 | reply("TEST"); 73 | } 74 | }); 75 | server.register(Plugin, function (err) { 76 | server.inject('/', function (res) { 77 | internals.asyncCheck(function () { 78 | expect(res.statusCode).to.equal(200); 79 | expect(res.payload).to.equal("TEST"); 80 | }, done); 81 | }); 82 | }); 83 | }); 84 | }); 85 | 86 | describe('ACL roles', function () { 87 | it('returns an error when a user with unsuited role tries to access a role protected route', function (done) { 88 | var server = new Hapi.Server(); 89 | server.connection(); 90 | server.auth.scheme('custom', internals.authSchema); 91 | server.auth.strategy('default', 'custom', true, {}); 92 | 93 | server.route({ 94 | method: 'GET', 95 | path : '/', 96 | config: { 97 | auth : 'default', 98 | plugins: { 99 | 'hapiAuthExtra': {role: 'ADMIN'} 100 | }, 101 | handler: function (request, reply) { reply("TEST");} 102 | } 103 | }); 104 | server.register(Plugin, function (err) { 105 | server.inject({method: 'GET', url: '/', credentials: {role: 'USER'}}, function (res) { 106 | internals.asyncCheck(function () { 107 | expect(res.statusCode).to.equal(401); 108 | expect(res.result.message).to.equal("Unauthorized"); 109 | }, done); 110 | }); 111 | }); 112 | }); 113 | 114 | it('Allows access to protected method for authorized users', function (done) { 115 | var server = new Hapi.Server(); 116 | server.connection(); 117 | server.auth.scheme('custom', internals.authSchema); 118 | server.auth.strategy('default', 'custom', true, {}); 119 | 120 | server.route({ 121 | method: 'GET', path: '/', config: { 122 | auth : 'default', 123 | plugins: {'hapiAuthExtra': {role: 'ADMIN'}}, 124 | handler: function (request, reply) { reply("Authorized");} 125 | } 126 | }); 127 | server.register(Plugin, function (err) { 128 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 129 | internals.asyncCheck(function () { 130 | expect(res.statusCode).to.equal(200); 131 | expect(res.payload).to.equal('Authorized'); 132 | }, done); 133 | }); 134 | }); 135 | }); 136 | }); 137 | 138 | describe('fetchEntity', function () { 139 | it('validates that the aclQuery parameter is a function', function (done) { 140 | var server = new Hapi.Server(); 141 | server.connection(); 142 | server.auth.scheme('custom', internals.authSchema); 143 | server.auth.strategy('default', 'custom', true, {}); 144 | 145 | server.route({ 146 | method: 'GET', path: '/', config: { 147 | auth : 'default', 148 | plugins: {'hapiAuthExtra': {aclQuery: 'not function'}}, 149 | handler: function (request, reply) { reply("Authorized");} 150 | } 151 | }); 152 | server.register(Plugin, function (err) { 153 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 154 | internals.asyncCheck(function () { 155 | expect(res.statusCode).to.equal(400); 156 | expect(res.result.message).to.match(/"aclQuery" must be a Function/); 157 | }, done); 158 | }); 159 | }); 160 | }); 161 | 162 | it('fetches the wanted entity using the query', function (done) { 163 | var server = new Hapi.Server(); 164 | server.connection(); 165 | server.auth.scheme('custom', internals.authSchema); 166 | server.auth.strategy('default', 'custom', true, {}); 167 | 168 | server.route({ 169 | method: 'GET', path: '/', config: { 170 | auth : 'default', 171 | plugins: { 172 | 'hapiAuthExtra': { 173 | aclQuery: function (id, cb) { 174 | cb(null, {id: '1', name: 'Asaf'}); 175 | } 176 | } 177 | }, 178 | handler: function (request, reply) { reply(request.plugins.hapiAuthExtra.entity);} 179 | } 180 | }); 181 | server.register(Plugin, function (err) { 182 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 183 | internals.asyncCheck(function () { 184 | expect(res.statusCode).to.equal(200); 185 | expect(res.result.name).to.equal('Asaf'); 186 | }, done); 187 | }); 188 | }); 189 | }); 190 | 191 | it('handles not found entities', function (done) { 192 | var server = new Hapi.Server(); 193 | server.connection(); 194 | server.auth.scheme('custom', internals.authSchema); 195 | server.auth.strategy('default', 'custom', true, {}); 196 | 197 | server.route({ 198 | method: 'GET', path: '/', config: { 199 | auth : 'default', 200 | plugins: { 201 | 'hapiAuthExtra': { 202 | aclQuery: function (id, cb) { 203 | cb(null, null); 204 | } 205 | } 206 | }, 207 | handler: function (request, reply) { reply("Oops");} 208 | } 209 | }); 210 | server.register(Plugin, function (err) { 211 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 212 | internals.asyncCheck(function () { 213 | expect(res.statusCode).to.equal(404); 214 | }, done); 215 | }); 216 | }); 217 | }); 218 | 219 | it('handles query errors', function (done) { 220 | var server = new Hapi.Server(); 221 | server.connection(); 222 | server.auth.scheme('custom', internals.authSchema); 223 | server.auth.strategy('default', 'custom', true, {}); 224 | 225 | server.route({ 226 | method: 'GET', path: '/', config: { 227 | auth : 'default', 228 | plugins: { 229 | 'hapiAuthExtra': { 230 | aclQuery: function (id, cb) { 231 | cb(new Error("Boomy"), null); 232 | } 233 | } 234 | }, 235 | handler: function (request, reply) { reply("Oops");} 236 | } 237 | }); 238 | server.register(Plugin, function (err) { 239 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 240 | internals.asyncCheck(function () { 241 | expect(res.statusCode).to.equal(500); 242 | }, done); 243 | }); 244 | }); 245 | }); 246 | }); 247 | 248 | describe('validateEntityAcl', function () { 249 | it('requires aclQuery when validateEntityAcl is true', function (done) { 250 | var server = new Hapi.Server(); 251 | server.connection(); 252 | server.auth.scheme('custom', internals.authSchema); 253 | server.auth.strategy('default', 'custom', true, {}); 254 | 255 | server.route({ 256 | method: 'GET', path: '/', config: { 257 | auth : 'default', 258 | plugins: {'hapiAuthExtra': {validateEntityAcl: true}}, 259 | handler: function (request, reply) { reply("Authorized");} 260 | } 261 | }); 262 | server.register(Plugin, function (err) { 263 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 264 | internals.asyncCheck(function () { 265 | expect(res.statusCode).to.equal(400); 266 | expect(res.result.message).to.match(/"aclQuery" is required/); 267 | }, done); 268 | }); 269 | }); 270 | }); 271 | 272 | it('returns an error when the entity was not found', function (done) { 273 | var server = new Hapi.Server(); 274 | server.connection(); 275 | server.auth.scheme('custom', internals.authSchema); 276 | server.auth.strategy('default', 'custom', true, {}); 277 | 278 | server.route({ 279 | method: 'GET', path: '/', config: { 280 | auth : 'default', 281 | plugins: { 282 | 'hapiAuthExtra': { 283 | validateEntityAcl: true, 284 | aclQuery : function (id, cb) { 285 | cb(null, null); 286 | } 287 | } 288 | }, 289 | handler: function (request, reply) { reply("Authorized");} 290 | } 291 | }); 292 | server.register(Plugin, function (err) { 293 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 294 | internals.asyncCheck(function () { 295 | expect(res.statusCode).to.equal(404); 296 | }, done); 297 | }); 298 | }); 299 | }); 300 | 301 | it('declines requests from unauthorized users', function (done) { 302 | var server = new Hapi.Server(); 303 | server.connection(); 304 | server.auth.scheme('custom', internals.authSchema); 305 | server.auth.strategy('default', 'custom', true, {}); 306 | 307 | server.route({ 308 | method: 'GET', path: '/', config: { 309 | auth : 'default', 310 | plugins: { 311 | 'hapiAuthExtra': { 312 | validateEntityAcl: true, 313 | validateAclMethod: 'isGranted', 314 | aclQuery : function (id, cb) { 315 | cb(null, {id: id, name: 'Hello', isGranted: function (user, role, cb) {cb(null, false)}}); 316 | } 317 | } 318 | }, 319 | handler: function (request, reply) { reply("Authorized");} 320 | } 321 | }); 322 | server.register(Plugin, function (err) { 323 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 324 | internals.asyncCheck(function () { 325 | expect(res.statusCode).to.equal(401); 326 | }, done); 327 | }); 328 | }); 329 | }); 330 | 331 | it('handles validator errors', function (done) { 332 | var server = new Hapi.Server(); 333 | server.connection(); 334 | server.auth.scheme('custom', internals.authSchema); 335 | server.auth.strategy('default', 'custom', true, {}); 336 | 337 | server.route({ 338 | method: 'GET', path: '/', config: { 339 | auth : 'default', 340 | plugins: { 341 | 'hapiAuthExtra': { 342 | validateEntityAcl: true, 343 | validateAclMethod: 'isGranted', 344 | aclQuery : function (id, cb) { 345 | cb(null, {id: id, name: 'Hello', isGranted: function (user, role, cb) {cb(new Error('Boom'))}}); 346 | } 347 | } 348 | }, 349 | handler: function (request, reply) { reply("Authorized");} 350 | } 351 | }); 352 | server.register(Plugin, function (err) { 353 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 354 | internals.asyncCheck(function () { 355 | expect(res.statusCode).to.equal(500); 356 | }, done); 357 | }); 358 | }); 359 | }); 360 | 361 | it('returns the response for authorized users', function (done) { 362 | var server = new Hapi.Server(); 363 | server.connection(); 364 | server.auth.scheme('custom', internals.authSchema); 365 | server.auth.strategy('default', 'custom', true, {}); 366 | 367 | server.route({ 368 | method: 'GET', path: '/', config: { 369 | auth : 'default', 370 | plugins: { 371 | 'hapiAuthExtra': { 372 | validateEntityAcl: true, 373 | validateAclMethod: 'isGranted', 374 | aclQuery : function (id, cb) { 375 | cb(null, {id: id, name: 'Hello', isGranted: function (user, role, cb) {cb(null, true)}}); 376 | } 377 | } 378 | }, 379 | handler: function (request, reply) { 380 | reply(request.plugins.hapiAuthExtra.entity); 381 | } 382 | } 383 | }); 384 | server.register(Plugin, function (err) { 385 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 386 | internals.asyncCheck(function () { 387 | expect(res.statusCode).to.equal(200); 388 | expect(res.result.name).to.equal('Hello'); 389 | }, done); 390 | }); 391 | }); 392 | }); 393 | }); 394 | 395 | }); 396 | 397 | 398 | describe('default acl validator', function () { 399 | 400 | it('returns error when the entity has no user field', function (done) { 401 | var server = new Hapi.Server(); 402 | server.connection(); 403 | server.auth.scheme('custom', internals.authSchema); 404 | server.auth.strategy('default', 'custom', true, {}); 405 | 406 | server.route({ 407 | method: 'GET', path: '/', config: { 408 | auth : 'default', 409 | plugins: { 410 | 'hapiAuthExtra': { 411 | validateEntityAcl: true, 412 | aclQuery : function (id, cb) { 413 | cb(null, {id: id, name: 'Hello'}); 414 | } 415 | } 416 | }, 417 | handler: function (request, reply) { reply("Authorized");} 418 | } 419 | }); 420 | server.register(Plugin, function (err) { 421 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN'}}, function (res) { 422 | internals.asyncCheck(function () { 423 | expect(res.statusCode).to.equal(401); 424 | expect(res.result.message).to.equal("Unauthorized"); 425 | }, done); 426 | }); 427 | }); 428 | }); 429 | 430 | it('returns error when the entity doesn\'t belong to the authenticated user', function (done) { 431 | var server = new Hapi.Server(); 432 | server.connection(); 433 | server.auth.scheme('custom', internals.authSchema); 434 | server.auth.strategy('default', 'custom', true, {}); 435 | 436 | server.route({ 437 | method: 'GET', path: '/', config: { 438 | auth : 'default', 439 | plugins: { 440 | 'hapiAuthExtra': { 441 | validateEntityAcl: true, 442 | aclQuery : function (id, cb) { 443 | cb(null, {_user: '1', name: 'Hello'}); 444 | } 445 | } 446 | }, 447 | handler: function (request, reply) { reply("Authorized");} 448 | } 449 | }); 450 | server.register(Plugin, function (err) { 451 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN', _id: '2'}}, function (res) { 452 | internals.asyncCheck(function () { 453 | expect(res.statusCode).to.equal(401); 454 | expect(res.result.message).to.equal("Unauthorized"); 455 | }, done); 456 | }); 457 | }); 458 | }); 459 | 460 | it('returns the response for user with permissions', function (done) { 461 | var server = new Hapi.Server(); 462 | server.connection(); 463 | server.auth.scheme('custom', internals.authSchema); 464 | server.auth.strategy('default', 'custom', true, {}); 465 | 466 | server.route({ 467 | method: 'GET', path: '/', config: { 468 | auth : 'default', 469 | plugins: { 470 | 'hapiAuthExtra': { 471 | validateEntityAcl: true, 472 | aclQuery : function (id, cb) { 473 | cb(null, {_user: '1', name: 'Hello'}); 474 | } 475 | } 476 | }, 477 | handler: function (request, reply) { reply("Authorized");} 478 | } 479 | }); 480 | server.register(Plugin, function (err) { 481 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN', _id: '1'}}, function (res) { 482 | internals.asyncCheck(function () { 483 | expect(res.statusCode).to.equal(200); 484 | expect(res.result).to.equal("Authorized"); 485 | }, done); 486 | }); 487 | }); 488 | }); 489 | 490 | it('handles custom user id field', function (done) { 491 | var server = new Hapi.Server(); 492 | server.connection(); 493 | server.auth.scheme('custom', internals.authSchema); 494 | server.auth.strategy('default', 'custom', true, {}); 495 | 496 | server.route({ 497 | method: 'GET', path: '/', config: { 498 | auth : 'default', 499 | plugins: { 500 | 'hapiAuthExtra': { 501 | validateEntityAcl: true, 502 | userIdField : 'myId', 503 | aclQuery : function (id, cb) { 504 | cb(null, {_user: '1', name: 'Hello'}); 505 | } 506 | } 507 | }, 508 | handler: function (request, reply) { reply("Authorized");} 509 | } 510 | }); 511 | server.register(Plugin, function (err) { 512 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN', myId: '1'}}, function (res) { 513 | internals.asyncCheck(function () { 514 | expect(res.statusCode).to.equal(200); 515 | expect(res.result).to.equal("Authorized"); 516 | }, done); 517 | }); 518 | }); 519 | }); 520 | 521 | it('handles custom entity user field', function (done) { 522 | var server = new Hapi.Server(); 523 | server.connection(); 524 | server.auth.scheme('custom', internals.authSchema); 525 | server.auth.strategy('default', 'custom', true, {}); 526 | 527 | server.route({ 528 | method: 'GET', path: '/', config: { 529 | auth : 'default', 530 | plugins: { 531 | 'hapiAuthExtra': { 532 | validateEntityAcl: true, 533 | entityUserField : 'creator', 534 | aclQuery : function (id, cb) { 535 | cb(null, {creator: '1', name: 'Hello'}); 536 | } 537 | } 538 | }, 539 | handler: function (request, reply) { reply("Authorized");} 540 | } 541 | }); 542 | server.register(Plugin, function (err) { 543 | server.inject({method: 'GET', url: '/', credentials: {role: 'ADMIN', _id: '1'}}, function (res) { 544 | internals.asyncCheck(function () { 545 | expect(res.statusCode).to.equal(200); 546 | expect(res.result).to.equal("Authorized"); 547 | }, done); 548 | }); 549 | }); 550 | }); 551 | }); 552 | 553 | 554 | internals.authSchema = function () { 555 | var scheme = { 556 | authenticate: function (request, reply) { 557 | return reply(null, {username: "asafdav", role: 'USER'}); 558 | }, 559 | payload : function (request, next) { 560 | 561 | return next(request.auth.credentials.payload); 562 | }, 563 | response : function (request, next) { 564 | 565 | return next(); 566 | } 567 | }; 568 | 569 | return scheme; 570 | }; 571 | 572 | internals.asyncCheck = function (f, done) { 573 | try { 574 | f(); 575 | done(); 576 | } catch (e) { 577 | done(e); 578 | } 579 | }; --------------------------------------------------------------------------------