├── .npmignore ├── .gitignore ├── .travis.yml ├── .eslintrc ├── LICENSE ├── package.json ├── test ├── setup.spec.js └── checks.spec.js ├── README.md └── src └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "5" 5 | - "4" 6 | - "0.12" 7 | after_success: 'npm run lint && npm run coveralls' 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true 5 | }, 6 | "rules": { 7 | "comma-dangle": 0, 8 | "arrow-body-style": 0, 9 | "arrow-parens": 0, 10 | "padded-blocks": 0, 11 | "no-underscore-dangle": 0, 12 | "prefer-rest-params": 0, 13 | "func-names": 0, 14 | "no-use-before-define": 0, 15 | "no-unused-vars": ["error", { "vars": "all", "args": "none" }], 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Julian Fell 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. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "authorizr", 3 | "version": "0.1.3", 4 | "description": "Minimalist authorisation mechanism for node", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "ava --verbose test", 8 | "lint": "eslint src/ test/", 9 | "coverage": "nyc ava test", 10 | "prepublish": "npm run test && BABEL_ENV=production babel --out-dir=lib src", 11 | "report": "nyc report --reporter=html", 12 | "coveralls": "npm run coverage && nyc report --reporter=text-lcov | coveralls" 13 | }, 14 | "ava": { 15 | "require": [ 16 | "babel-register" 17 | ] 18 | }, 19 | "babel": { 20 | "presets": [ 21 | "es2015" 22 | ], 23 | "plugins": [ 24 | "transform-es2015-modules-commonjs" 25 | ], 26 | "ignore": "__test__", 27 | "env": { 28 | "development": { 29 | "sourceMaps": "inline" 30 | } 31 | } 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/jtfell/authorizr.git" 36 | }, 37 | "author": "Julian Fell", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/jtfell/authorizr/issues" 41 | }, 42 | "homepage": "https://github.com/jtfell/authorizr#readme", 43 | "devDependencies": { 44 | "ava": "^0.16.0", 45 | "babel-cli": "^6.16.0", 46 | "babel-plugin-transform-es2015-modules-commonjs": "^6.16.0", 47 | "babel-preset-es2015": "^6.16.0", 48 | "babel-register": "^6.16.3", 49 | "coveralls": "^2.11.14", 50 | "eslint": "^3.7.0", 51 | "eslint-config-airbnb-base": "^8.0.0", 52 | "eslint-plugin-import": "^1.16.0", 53 | "nyc": "^8.3.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/setup.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import test from 'ava'; 3 | import Authorizr from '../src'; 4 | 5 | test('context returned via promise is passed to checks', async t => { 6 | const authorizr = new Authorizr(() => { 7 | return new Promise((resolve, reject) => 8 | setTimeout(() => resolve({ foo: 'bar' }), 250) 9 | ); 10 | }); 11 | 12 | authorizr.addEntity( 13 | 'entity', 14 | { 15 | check: (ctx, entityArgs, args) => t.is(ctx.foo, 'bar') 16 | } 17 | ); 18 | 19 | const auth = authorizr.newRequest({}); 20 | return auth.entity().check().any(); 21 | }); 22 | 23 | test('rejected promise causes no checks to be run', async t => { 24 | const authorizr = new Authorizr(() => { 25 | return new Promise((resolve, reject) => { 26 | throw new Error('error!'); 27 | }); 28 | }); 29 | 30 | authorizr.addEntity( 31 | 'entity', 32 | { 33 | check: (ctx, entityArgs, args) => t.fail() 34 | } 35 | ); 36 | 37 | const auth = authorizr.newRequest(); 38 | return auth.entity().check().any().catch(err => { 39 | t.truthy(err instanceof Error); 40 | }); 41 | }); 42 | 43 | test('context returned directly is passed to checks', async t => { 44 | const authorizr = new Authorizr(() => { 45 | return { foo: 'bar' }; 46 | }); 47 | 48 | authorizr.addEntity( 49 | 'entity', 50 | { 51 | check: (ctx, entityArgs, args) => t.is(ctx.foo, 'bar') 52 | } 53 | ); 54 | 55 | const auth = authorizr.newRequest({}); 56 | return auth.entity().check(); 57 | }); 58 | 59 | test('entityId is passed to checks', async t => { 60 | const authorizr = new Authorizr(() => { 61 | return { foo: 'bar' }; 62 | }); 63 | 64 | authorizr.addEntity( 65 | 'entity', 66 | { 67 | check: (ctx, entityId, args) => t.is(entityId, 'bar') 68 | } 69 | ); 70 | 71 | const auth = authorizr.newRequest({}); 72 | return auth.entity('bar').check(); 73 | }); 74 | 75 | test('checkArgs are passed to checks', async t => { 76 | const authorizr = new Authorizr(() => { 77 | return { foo: 'bar' }; 78 | }); 79 | 80 | authorizr.addEntity( 81 | 'entity', 82 | { 83 | check: (ctx, entityArgs, args) => t.is(args[0], 'bar') 84 | } 85 | ); 86 | 87 | const auth = authorizr.newRequest({}); 88 | return auth.entity().check('bar'); 89 | }); 90 | -------------------------------------------------------------------------------- /test/checks.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import test from 'ava'; 3 | import Authorizr from '../src'; 4 | 5 | let authorizr; 6 | test.beforeEach(() => { 7 | authorizr = new Authorizr(() => { 8 | return { foo: 'bar' }; 9 | }); 10 | }); 11 | 12 | test('throwing an error in a check will make the chain return a rejected promise', async t => { 13 | authorizr.addEntity( 14 | 'entity', 15 | { 16 | check: () => { 17 | throw new Error('error!'); 18 | } 19 | } 20 | ); 21 | 22 | const auth = authorizr.newRequest({}); 23 | return auth.entity().check().any().catch(err => t.truthy(err instanceof Error)); 24 | }); 25 | 26 | test('throwing an error in a check will make the chain return a rejected promise', async t => { 27 | authorizr.addEntity( 28 | 'entity', 29 | { 30 | check: () => { 31 | throw new Error('error!'); 32 | } 33 | } 34 | ); 35 | 36 | const auth = authorizr.newRequest({}); 37 | return auth.entity().check().all().catch(err => t.truthy(err instanceof Error)); 38 | }); 39 | 40 | test('returning false in single check will cause all to resolve to false', async t => { 41 | authorizr.addEntity( 42 | 'entity', 43 | { 44 | check: () => false 45 | } 46 | ); 47 | 48 | const auth = authorizr.newRequest({}); 49 | return auth.entity().check().all().then(res => t.is(res, false)); 50 | }); 51 | 52 | test('returning a rejected promise will cause all to resolve to false', async t => { 53 | authorizr.addEntity( 54 | 'entity', 55 | { 56 | check: () => Promise.reject('error!') 57 | } 58 | ); 59 | 60 | const auth = authorizr.newRequest({}); 61 | return auth.entity().check().all().catch(res => t.truthy(res === 'error!')); 62 | }); 63 | 64 | test('returning a promise that resolves to true will cause all to resolve to true', async t => { 65 | authorizr.addEntity( 66 | 'entity', 67 | { 68 | check: () => Promise.resolve(true) 69 | } 70 | ); 71 | 72 | const auth = authorizr.newRequest({}); 73 | return auth.entity().check().all().then(res => t.is(res, true)); 74 | }); 75 | 76 | test('returning true in single check will cause all to resolve to true', async t => { 77 | authorizr.addEntity( 78 | 'entity', 79 | { 80 | check: () => true 81 | } 82 | ); 83 | 84 | const auth = authorizr.newRequest({}); 85 | return auth.entity().check().all().then(res => t.is(res, true)); 86 | }); 87 | 88 | test('returning false in any check will cause all to resolve to false', async t => { 89 | authorizr.addEntity( 90 | 'entity', 91 | { 92 | check: () => true, 93 | check2: () => false 94 | } 95 | ); 96 | 97 | const auth = authorizr.newRequest({}); 98 | return auth.entity() 99 | .check() 100 | .check2() 101 | .all() 102 | .then(res => t.is(res, false)); 103 | }); 104 | 105 | test('returning true in any check will cause any to resolve to true', async t => { 106 | authorizr.addEntity( 107 | 'entity', 108 | { 109 | check: () => true, 110 | check2: () => false, 111 | check3: () => false 112 | } 113 | ); 114 | 115 | const auth = authorizr.newRequest({}); 116 | return auth.entity() 117 | .check() 118 | .check2() 119 | .check3() 120 | .any() 121 | .then(res => t.is(res, true)); 122 | }); 123 | 124 | test('multiple entities from a single request should function correctly', async t => { 125 | authorizr.addEntity( 126 | 'entity', 127 | { 128 | check: () => true, 129 | check2: () => false 130 | } 131 | ); 132 | 133 | const auth = authorizr.newRequest({}); 134 | const pAny = auth.entity() 135 | .check() 136 | .check2() 137 | .any() 138 | .then(res => t.is(res, true)); 139 | const pAll1 = auth.entity() 140 | .check() 141 | .check2() 142 | .all() 143 | .then(res => t.is(res, false)); 144 | const pAll2 = auth.entity() 145 | .check2() 146 | .check() 147 | .all() 148 | .then(res => t.is(res, false)); 149 | 150 | return Promise.all([pAll1, pAll2, pAny]); 151 | }); 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # authorizr 2 | 3 | [![Build Status](https://travis-ci.org/jtfell/authorizr.svg?branch=master)](https://travis-ci.org/jtfell/authorizr) 4 | [![Coverage Status](https://coveralls.io/repos/github/jtfell/authorizr/badge.svg?branch=master)](https://coveralls.io/github/jtfell/authorizr?branch=master) 5 | 6 | Minimalist authorisation mechanism for node servers :zap:. Designed for efficient use in GraphQL servers, authorizr allows 7 | flexible and easy to reason about authoristion checks. By creating a new authorizr object per request, the implementation 8 | is free to pre-optimise as much or as little of the heavy lifting as desired. 9 | 10 | I've written a little on the idea behind this library on [my blog](https://jtfell.com/blog/posts/2017-02-06-authorisation-revisited.html). 11 | 12 | ## Install 13 | 14 | `npm install authorizr` 15 | 16 | ## Example Usage 17 | 18 | Create a new authorizr. 19 | 20 | ```js 21 | import Authorizr from 'authorizr'; 22 | 23 | // Create a new authorisation object 24 | const authorizr = new Authorizr(context => { 25 | 26 | // Do any pre-calculation per instance (eg. get commonly used info from db) 27 | return new Promise((resolve, reject) => { 28 | const teams = db.findUserTeams(context.userId); 29 | const perms = db.findUserPermissions(context.userId); 30 | 31 | Promise.all([teams, perms]) 32 | .then(res => { 33 | 34 | // Resolve the promise with data that is passed into every auth check 35 | resolve({ userId: context.userId, teams: res[0], perms: res[1] }) 36 | }); 37 | }); 38 | }); 39 | 40 | authorizr.addEntity( 41 | 'team', 42 | { 43 | // Each check function is passed the pre-calculated global context, any arguments 44 | // passed into the entity and any arguments passed into the specific check 45 | isOwner: (ctx, entityId, args) => ctx.teams[entityId].owner === ctx.userId, 46 | isAdmin: (ctx, entityId, args) => ctx.teams[entityId].admin === ctx.userId 47 | } 48 | ); 49 | ``` 50 | 51 | Create a new authorizr instance using the context of the request (before the graphql query is executed). This allows the authorizr to 52 | setup all the checks for the user making the request. 53 | 54 | ```js 55 | req.ctx.auth = authorizr.newRequest(ctx); 56 | ``` 57 | 58 | Use the checks in an easily readable way in the resolve functions. 59 | 60 | ```js 61 | resolve: function(id, args, { auth }) { 62 | 63 | auth.team(id) 64 | .isOwner() 65 | .isAdmin() 66 | .any() 67 | .then(res => 68 | if (res) { 69 | // Do protected access 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | ## API 76 | 77 | #### `new Authorizr(setupFn [, options])` 78 | 79 | Create a new `Authorizr` instance. 80 | 81 | - *setupFn*: A function that accepts arbitrary inputs and does pre-optimisation for each request. Returns an arbitrary object, or a promise resolving to an arbitrary object, that will be passed to each individual authorisation check. 82 | 83 | - *options*: An optional object of options: 84 | - *cache*: Default `true`. Set to false to disable caching each authorisation check. 85 | 86 | #### `addEntity(name, checks)` 87 | 88 | Adds an entity for doing authorisation checks against. 89 | 90 | - *name*: The name of the function to be called when authorising requests. 91 | - *checks*: An object with check names mapping to functions for completing each check. Each check has the signature: 92 | `check(globalCtx, entityArgs, checkArgs)` 93 | - *globalCtx*: The result of the `setupFn` for this request. 94 | - *entityId*: The argument passed to the entity auth call (usually identifying the entity to perform the check against. 95 | - *checkArgs*: The arguments passed to the individual auth check. 96 | 97 | #### `newRequest(context)` 98 | 99 | Creates a new context for authorisation calls. The `setupFn` will be called as part of this initialisation. 100 | 101 | - *context*: Any context needed for authorisation, passed directly into `setupFn`. Usually identification about who is making the request. 102 | 103 | #### `entity(entityId)` 104 | 105 | Identifies an entity for completing authorisation checks against and returns an object with chainable check methods from the `addEntity` call. 106 | 107 | - *entityId*: Argument used to identify the entity. 108 | 109 | #### `check(checkArgs)` 110 | 111 | Completes an authorisation check using context from the request and entity calls. Th 112 | 113 | - *checkArgs*: Arguments used to pass in information needed for the check 114 | 115 | #### `all()` 116 | 117 | Returns a promise resolving to true if *all* the checks passed, otherwise resolving to false. 118 | 119 | #### `any()` 120 | 121 | Returns a promise resolving to true if *any* the checks passed, otherwise resolving to false. 122 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constructor function 3 | * 4 | * @param {Function} setupFn 5 | */ 6 | function Authorizr(setupFn) { 7 | this.__setupFn = setupFn; 8 | } 9 | 10 | /** 11 | * Creates a new request instance and runs the setup function 12 | * based on the input obj passed in. 13 | * 14 | * @param {Object} input - An arbitrary object to be passed into 15 | * the setup Function 16 | */ 17 | Authorizr.prototype.newRequest = function (input) { 18 | this.__setupResult = Promise.resolve(this.__setupFn(input)); 19 | return this; 20 | }; 21 | 22 | /** 23 | * Defines a new entity to be used on any request instance. 24 | * 25 | * @param {String} name - Name of entity 26 | * @param {Object} checks - Mapping from check name to check 27 | * function for entity 28 | */ 29 | Authorizr.prototype.addEntity = function (name, checks) { 30 | this[name] = this.__createEntityCallHandler(name, checks); 31 | }; 32 | 33 | /** 34 | * Uses a closure to return a function that sets the entity ID, then returns 35 | * the entity. 36 | * 37 | * @param {Entity} entity 38 | */ 39 | Authorizr.prototype.__createEntityCallHandler = function (name, checks) { 40 | return function entityCallHandler(entityId) { 41 | const entity = new Entity(name, checks); 42 | entity.__setContext(entityId, this.__setupResult); 43 | return entity; 44 | }.bind(this); 45 | }; 46 | 47 | /** 48 | * Entity type constructor. Handles chaining checks together from a 49 | * single entity ID. 50 | * 51 | * @param {String} name 52 | * @param {Object} checks 53 | */ 54 | function Entity(name, checks) { 55 | this.__entityId = null; 56 | this.__name = name; 57 | 58 | this.__setupResult = null; 59 | this.__setupErr = null; 60 | 61 | this.__activeChecks = []; 62 | 63 | Object.keys(checks).forEach(checkName => { 64 | 65 | const fn = checks[checkName]; 66 | 67 | // Add error checking and account for promises in user-defined check 68 | const check = wrapCheck(fn); 69 | // Attach a generic check handler to catch the check calls 70 | this[checkName] = this.__createCheckCallHandler(check); 71 | }); 72 | } 73 | 74 | /** 75 | * Setter function for entity ID. 76 | * 77 | * @param {Any} id 78 | */ 79 | Entity.prototype.__setContext = function (id, setupResult) { 80 | this.__entityId = id; 81 | this.__setupResult = setupResult; 82 | }; 83 | 84 | /** 85 | * Returns a promise that resolves to true if any of the 86 | * checks resolve to true. 87 | */ 88 | Entity.prototype.any = function () { 89 | if (this.__setupErr) { 90 | throw this.__setupErr; 91 | } 92 | return Promise.all(this.__activeChecks) 93 | .then(checkResults => { 94 | this.__activeChecks = []; 95 | 96 | checkResults.forEach(res => { 97 | if (res instanceof Error) { 98 | throw res; 99 | } 100 | }); 101 | 102 | // Start with false, then let any true value switch the final 103 | // result to true 104 | return checkResults.reduce((prev, result) => prev || result, false); 105 | }); 106 | }; 107 | 108 | /** 109 | * Returns a promise that resolves to true if all of the 110 | * checks resolve to true. 111 | */ 112 | Entity.prototype.all = function () { 113 | if (this.__setupErr) { 114 | throw this.__setupErr; 115 | } 116 | return Promise.all(this.__activeChecks) 117 | .then(checkResults => { 118 | this.__activeChecks = []; 119 | 120 | checkResults.forEach(res => { 121 | if (res instanceof Error) { 122 | throw res; 123 | } 124 | }); 125 | 126 | // Start with true, then let any false value switch the final 127 | // result to false 128 | return checkResults.reduce((prev, result) => prev && result, true); 129 | }); 130 | }; 131 | 132 | /** 133 | * Creates a check handler that allows chainable check calls. 134 | * 135 | * @param {Function} check 136 | */ 137 | Entity.prototype.__createCheckCallHandler = function (check) { 138 | return function checkCallHandler() { 139 | const checkArgs = arguments; 140 | 141 | const newCheckRes = this.__setupResult.then(globalCtx => { 142 | return check(globalCtx, this.__entityId, checkArgs); 143 | }).catch(err => { 144 | return err; 145 | }); 146 | 147 | this.__activeChecks.push(newCheckRes); 148 | 149 | // Chainable 150 | return this; 151 | }.bind(this); 152 | }; 153 | 154 | 155 | /** 156 | * Helper function to wrap check functions in a try/catch and 157 | * handle promises and direct return values consistently. 158 | * 159 | * @param {Function} fn 160 | */ 161 | function wrapCheck(fn) { 162 | return (globalCtx, entityId, checkArgs) => { 163 | return new Promise(resolve => { 164 | 165 | // Call the user-defined check 166 | try { 167 | const res = fn(globalCtx, entityId, checkArgs); 168 | const p = Promise.resolve(res); 169 | return p.then(result => resolve(result)) 170 | .catch(err => resolve(err)); 171 | 172 | } catch (e) { 173 | // Resolve the error to be handled in the final call of the chain 174 | return resolve(e); 175 | } 176 | 177 | }); 178 | }; 179 | } 180 | 181 | export default Authorizr; 182 | --------------------------------------------------------------------------------