├── .editorconfig ├── .gitattributes ├── .github ├── funding.yml └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example.js ├── package.json ├── src └── index.js ├── test.js └── types ├── index.d.ts └── index.test-d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: hsynlms 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 22 14 | - 20 15 | - 18 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .gitattributes 3 | .gitignore 4 | .npmignore 5 | .npmrc 6 | .github 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### How to contribute to the library? 2 | 3 | - Make changes in the `/src` directory. 4 | - Before sending a pull request for a feature or bug fix, be sure to have [tests](https://github.com/hsynlms/fastify-guard/blob/master/test.js). 5 | - Use the same coding style as the rest of the codebase. The library is [Standard JS](https://standardjs.com/) compatible. 6 | - All pull requests should be made to the master branch. 7 | - Send commit summaries by following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#specification) specifications as possible as you can. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Huseyin ELMAS 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-guard 2 | > A simple user role and scope check plugin to protect endpoints for [Fastify](https://github.com/fastify/fastify). 3 | 4 | [![Downloads](https://img.shields.io/npm/dm/fastify-guard.svg)](https://npmjs.com/fastify-guard) 5 | [![install size](https://packagephobia.com/badge?p=fastify-guard)](https://packagephobia.com/result?p=fastify-guard) 6 | 7 | `fastify-guard` is designed to protect API endpoints by checking authenticated user roles and/or scopes if they met. `guard` is the registered Fastify decorator and can be used in anywhere. 8 | 9 | Inspired by [express-jwt-permissions](https://github.com/MichielDeMey/express-jwt-permissions). 10 | 11 | ## Install 12 | ``` 13 | $ npm install fastify-guard 14 | ``` 15 | 16 | ### Compatibility 17 | 18 | | Plugin version | Fastify version | 19 | | -------------- |---------------- | 20 | | `^3.0.0` | `^5.0.0` | 21 | | `^2.0.0` | `^4.0.0` | 22 | | `^1.0.0` | `^3.0.0` | 23 | 24 | ## Usage 25 | 26 | ```js 27 | const fastify = require('fastify')() 28 | const fastifyGuard = require('fastify-guard') 29 | 30 | fastify.register( 31 | fastifyGuard, 32 | { 33 | errorHandler: (result, req, reply) => { 34 | return reply.send('you are not allowed to call this route') 35 | } 36 | } 37 | ) 38 | 39 | // this route can only be called by users who has 'cto' and 'admin' roles 40 | fastify.get( 41 | '/admin', 42 | { preHandler: [fastify.guard.role(['cto', 'admin'])] }, 43 | (req, reply) => { 44 | // 'user' should already be defined in req object 45 | reply.send(req.user) 46 | } 47 | ) 48 | 49 | // this route can only be called by users who has 'admin' or 'editor' role 50 | fastify.get( 51 | '/', 52 | { preHandler: [fastify.guard.role('admin', 'editor')] }, 53 | (req, reply) => { 54 | // 'user' should already be defined in req object 55 | reply.send(req.user) 56 | } 57 | ) 58 | 59 | /* 60 | http://localhost:3000 -> will print out below result if the authenticated user does not have 'admin' role 61 | 62 | you are not allowed to call this route 63 | */ 64 | 65 | fastify.get( 66 | '/has-role', 67 | (req, reply) => { 68 | // 'user' should already be defined in req object 69 | reply.send( 70 | fastify.guard.hasRole(req, 'admin') // will return a boolean value 71 | ) 72 | } 73 | ) 74 | 75 | fastify.get( 76 | '/has-scope', 77 | (req, reply) => { 78 | // 'user' should already be defined in req object 79 | reply.send( 80 | fastify.guard.hasScope(req, 'profile') // will return a boolean value 81 | ) 82 | } 83 | ) 84 | 85 | fastify.listen(3000, () => { 86 | console.log('Fastify server is running on port: 3000') 87 | }) 88 | 89 | /* 90 | http://localhost:3000 -> will print out below result if the authenticated user does not have 'admin' role 91 | 92 | you are not allowed to call this route 93 | */ 94 | ``` 95 | 96 | ## Options 97 | 98 | | Name | Type | Default | Description | 99 | | --- | --- | --- | --- | 100 | | requestProperty | string | `user` | The authenticated user property name that fastify-guard will search in request object | 101 | | roleProperty | string | `role` | The role property name that fastify-guard will search in authenticated user object | 102 | | scopeProperty | string | `scope` | The scope property name that fastify-guard will search in authenticated user object | 103 | | errorHandler | function | undefined | Custom error handler to manipulate the response that will be returned. As fallback, default HTTP error messages will be returned. | 104 | 105 | ## API 106 | 107 | ### `guard.role(role)` 108 | 109 | Returns a function which checks if the authenticated user has the given role(s). Multiple roles can be sent as separated parameters or in an array. If the given role(s) was not assigned to the authenticated user, the function will throw an HTTP Error (if no errorHandler provided in options otherwise the errorHandler will be invoked). The function supposed to be used in `preHandler` hook. 110 | 111 | ### `guard.hasRole(request, role)` 112 | 113 | Returns a boolean value which indicates the authenticated user has the given role. 114 | 115 | `request` is the Fastify request object 116 | 117 | `role` is role name 118 | 119 | ### `guard.scope(scope)` 120 | 121 | Returns a function which checks if the authenticated user has the given scope(s). Multiple scopes can be sent as separated parameters or in an array. If the given scope(s) was not assigned to the authenticated user, the function will throw an HTTP Error (if no errorHandler provided in options otherwise the errorHandler will be invoked). The function supposed to be used in `preHandler` hook. 122 | 123 | ### `guard.hasScope(request, scope)` 124 | 125 | Returns a boolean value which indicates the authenticated user has the given scope. 126 | 127 | `request` is the Fastify request object 128 | 129 | `scope` is scope name 130 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')() 4 | const fastifyGuard = require('./src/index') 5 | const chalk = require('chalk') 6 | 7 | const defaults = { port: 3000 } 8 | 9 | ;(async () => { 10 | await fastify.register(fastifyGuard) 11 | 12 | // simulation for user authentication process 13 | fastify.addHook('onRequest', (req, _, done) => { 14 | req.user = { 15 | id: 306, 16 | name: 'Huseyin', 17 | role: ['user', 'admin', 'editor'], 18 | scope: ['profile', 'email', 'openid'], 19 | location: 'Istanbul' 20 | } 21 | 22 | done() 23 | }) 24 | 25 | fastify.get( 26 | '/', 27 | { preHandler: [fastify.guard.role('admin')] }, 28 | (req, reply) => { 29 | reply 30 | .type('application/json') 31 | .send(req.user) 32 | } 33 | ) 34 | 35 | fastify.get( 36 | '/insufficient', 37 | { preHandler: [fastify.guard.role(['supervisor'])] }, 38 | (req, reply) => { 39 | reply 40 | .type('application/json') 41 | .send(req.user) 42 | } 43 | ) 44 | 45 | fastify.listen(defaults.port, () => { 46 | console.log( 47 | chalk.bgYellow( 48 | chalk.black(`Fastify server is running on port: ${defaults.port}`) 49 | ) 50 | ) 51 | }) 52 | })() 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-guard", 3 | "version": "3.0.1", 4 | "description": "A simple user role and scope checker plugin to protect endpoints for Fastify", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest --verbose -i test.js -t", 8 | "dev": "node example.js", 9 | "lint": "standard --verbose" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/hsynlms/fastify-guard.git" 14 | }, 15 | "keywords": [ 16 | "fastify", 17 | "guard", 18 | "permission", 19 | "role", 20 | "scope", 21 | "api", 22 | "protection" 23 | ], 24 | "author": "Huseyin ELMAS", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/hsynlms/fastify-guard/issues" 28 | }, 29 | "homepage": "https://github.com/hsynlms/fastify-guard#readme", 30 | "engines": { 31 | "node": ">=18.0.0" 32 | }, 33 | "dependencies": { 34 | "fastify-plugin": "^5.0.1", 35 | "http-errors": "^2.0.0" 36 | }, 37 | "devDependencies": { 38 | "@types/http-errors": "^2.0.4", 39 | "fastify": "^5.0.0", 40 | "jest": "^29.7.0", 41 | "standard": "^17.0.0", 42 | "tsd": "^0.31.2" 43 | }, 44 | "types": "types", 45 | "standard": { 46 | "ignore": [ 47 | "/types" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastifyPlugin = require('fastify-plugin') 4 | const createError = require('http-errors') 5 | 6 | const pkg = require('../package.json') 7 | 8 | const defaults = { 9 | decorator: 'guard', 10 | requestProperty: 'user', 11 | roleProperty: 'role', 12 | scopeProperty: 'scope' 13 | } 14 | 15 | const checkScopeAndRole = (arr, req, options, property) => { 16 | if (!arr.every(item => typeof item === 'string' || Array.isArray(item))) { 17 | return createError(500, 'roles/scopes parameter expected to be an array or string') 18 | } 19 | 20 | const user = req[options.requestProperty] 21 | if (!user) { 22 | return createError(500, `user object ${options.requestProperty} was not found in request object`) 23 | } 24 | 25 | let permissions = user[options[property]] 26 | if (!permissions) { 27 | return createError(500, `${property} was not found in the user object`) 28 | } 29 | 30 | if (typeof permissions === 'string') { 31 | permissions = permissions.split(' ') 32 | } 33 | 34 | if (!Array.isArray(permissions)) { 35 | return createError(500, `${property} expected to be an array but got: ${typeof permissions}`) 36 | } 37 | 38 | // loop roles/scopes array list (may contain sub arrays) 39 | const sufficient = arr.some(x => 40 | Array.isArray(x) 41 | ? x.every(scope => permissions.includes(scope)) 42 | : permissions.includes(x) 43 | ) 44 | 45 | return sufficient ? null : createError(403, 'insufficient permission') 46 | } 47 | 48 | const hasScopeAndRole = (value, req, options, property) => { 49 | if (typeof value !== 'string') { 50 | throw new Error(`role/scope value is expected to be a non-empty string but got: ${typeof value}`) 51 | } 52 | 53 | if (typeof req !== 'object') { 54 | throw new Error(`request is expected to be an object but got: ${typeof req}`) 55 | } 56 | 57 | if (!value) { 58 | throw new Error('role/scope value is expected to be a non-empty string') 59 | } 60 | 61 | const user = req[options.requestProperty] 62 | if (!user) { 63 | throw new Error(`user object ${options.requestProperty} was not found in request object`) 64 | } 65 | 66 | let permissions = user[options[property]] 67 | if (!permissions) { 68 | throw new Error(`${property} was not found in the user object`) 69 | } 70 | 71 | if (typeof permissions === 'string') { 72 | permissions = permissions.split(' ') 73 | } 74 | 75 | if (!Array.isArray(permissions)) { 76 | throw new Error(`${property} expected to be an array but got: ${typeof permissions}`) 77 | } 78 | 79 | return permissions.includes(value) 80 | } 81 | 82 | const Guard = function (options) { 83 | this._options = options 84 | } 85 | 86 | Guard.prototype = { 87 | hasRole: function (request, role) { 88 | return hasScopeAndRole(role, request, this._options, 'roleProperty') 89 | }, 90 | role: function (...roles) { 91 | // thanks javascript :) 92 | const self = this 93 | 94 | // middleware to check authenticated user role(s) 95 | return (req, reply, done) => { 96 | const result = checkScopeAndRole(roles, req, self._options, 'roleProperty') 97 | 98 | // use custom handler if possible 99 | if (result && self._options.errorHandler) { 100 | return self._options.errorHandler(result, req, reply) 101 | } 102 | 103 | // use predefined handler as fallback 104 | return done(result) 105 | } 106 | }, 107 | hasScope: function (request, scope) { 108 | return hasScopeAndRole(scope, request, this._options, 'scopeProperty') 109 | }, 110 | scope: function (...scopes) { 111 | // thanks javascript :) 112 | const self = this 113 | 114 | // middleware to check authenticated user scope(s) 115 | return (req, reply, done) => { 116 | const result = checkScopeAndRole(scopes, req, self._options, 'scopeProperty') 117 | 118 | // use custom handler if possible 119 | if (result && self._options.errorHandler) { 120 | return self._options.errorHandler(result, req, reply) 121 | } 122 | 123 | // use predefined handler as fallback 124 | return done(result) 125 | } 126 | } 127 | } 128 | 129 | function guardPlugin (fastify, opts, next) { 130 | const options = Object.assign({}, defaults, opts) 131 | 132 | if (options.errorHandler && typeof options.errorHandler !== 'function') { 133 | throw new Error('custom error handler must be a function') 134 | } 135 | 136 | fastify.decorate(options.decorator, new Guard(options)) 137 | 138 | next() 139 | } 140 | 141 | module.exports = fastifyPlugin( 142 | guardPlugin, 143 | { 144 | fastify: '5.x', 145 | name: pkg.name 146 | } 147 | ) 148 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const fastifyGuard = require('./src/index') 5 | 6 | const generateServer = 7 | async pluginOpts => { 8 | const fastify = new Fastify() 9 | 10 | await fastify.register(fastifyGuard, pluginOpts) 11 | 12 | // simulation for user authentication process 13 | fastify.addHook('onRequest', (req, _, done) => { 14 | req.user = { 15 | id: 306, 16 | name: 'Huseyin', 17 | role: ['user', 'admin', 'editor'], 18 | rl: 'user admin editor', 19 | scope: ['profile', 'email', 'openid'], 20 | scp: 'profile email openid', 21 | location: 'Istanbul' 22 | } 23 | 24 | done() 25 | }) 26 | 27 | return fastify 28 | } 29 | 30 | // test cases 31 | 32 | // eslint-disable-next-line 33 | test('sufficient hasRole check', done => { 34 | generateServer() 35 | .then(fastify => { 36 | fastify.get('/', (req, reply) => { 37 | const isOk = fastify.guard.hasRole(req, 'user') 38 | reply.send(isOk) 39 | }) 40 | 41 | fastify.inject( 42 | { method: 'GET', url: '/' }, 43 | // eslint-disable-next-line 44 | (err, res) => { 45 | // eslint-disable-next-line 46 | expect(res.payload).toBe('true') 47 | done() 48 | 49 | fastify.close() 50 | } 51 | ) 52 | }) 53 | }) 54 | 55 | // eslint-disable-next-line 56 | test('insufficient hasRole check', done => { 57 | generateServer() 58 | .then(fastify => { 59 | fastify.get('/', (req, reply) => { 60 | const isOk = fastify.guard.hasRole(req, 'cmo') 61 | reply.send(isOk) 62 | }) 63 | 64 | fastify.inject( 65 | { method: 'GET', url: '/' }, 66 | // eslint-disable-next-line 67 | (err, res) => { 68 | // eslint-disable-next-line 69 | expect(res.payload).toBe('false') 70 | done() 71 | 72 | fastify.close() 73 | } 74 | ) 75 | }) 76 | }) 77 | 78 | // eslint-disable-next-line 79 | test('hasRole argument validations', done => { 80 | generateServer() 81 | .then(fastify => { 82 | fastify.get('/', (req, reply) => { 83 | const isOk = 84 | fastify.guard.hasRole(req, '') || 85 | fastify.guard.hasRole(null, 'user') || 86 | fastify.guard.hasRole() 87 | 88 | reply.send(isOk) 89 | }) 90 | 91 | fastify.inject( 92 | { method: 'GET', url: '/' }, 93 | // eslint-disable-next-line 94 | (err, res) => { 95 | // eslint-disable-next-line 96 | expect(res.statusCode).toBe(500) 97 | done() 98 | 99 | fastify.close() 100 | } 101 | ) 102 | }) 103 | }) 104 | 105 | // eslint-disable-next-line 106 | test('sufficient hasScope check', done => { 107 | generateServer() 108 | .then(fastify => { 109 | fastify.get('/', (req, reply) => { 110 | const isOk = fastify.guard.hasScope(req, 'profile') 111 | reply.send(isOk) 112 | }) 113 | 114 | fastify.inject( 115 | { method: 'GET', url: '/' }, 116 | // eslint-disable-next-line 117 | (err, res) => { 118 | // eslint-disable-next-line 119 | expect(res.payload).toBe('true') 120 | done() 121 | 122 | fastify.close() 123 | } 124 | ) 125 | }) 126 | }) 127 | 128 | // eslint-disable-next-line 129 | test('insufficient hasScope check', done => { 130 | generateServer() 131 | .then(fastify => { 132 | fastify.get('/', (req, reply) => { 133 | const isOk = fastify.guard.hasScope(req, 'base') 134 | reply.send(isOk) 135 | }) 136 | 137 | fastify.inject( 138 | { method: 'GET', url: '/' }, 139 | // eslint-disable-next-line 140 | (err, res) => { 141 | // eslint-disable-next-line 142 | expect(res.payload).toBe('false') 143 | done() 144 | 145 | fastify.close() 146 | } 147 | ) 148 | }) 149 | }) 150 | 151 | // eslint-disable-next-line 152 | test('hasScope argument validations', done => { 153 | generateServer() 154 | .then(fastify => { 155 | fastify.get('/', (req, reply) => { 156 | const isOk = 157 | fastify.guard.hasScope(req, '') || 158 | fastify.guard.hasScope(null, 'profile') || 159 | fastify.guard.hasScope() 160 | 161 | reply.send(isOk) 162 | }) 163 | 164 | fastify.inject( 165 | { method: 'GET', url: '/' }, 166 | // eslint-disable-next-line 167 | (err, res) => { 168 | // eslint-disable-next-line 169 | expect(res.statusCode).toBe(500) 170 | done() 171 | 172 | fastify.close() 173 | } 174 | ) 175 | }) 176 | }) 177 | 178 | // eslint-disable-next-line 179 | test('sufficient role permission (check OR case by providing two roles as arguments)', done => { 180 | generateServer() 181 | .then(fastify => { 182 | fastify.get( 183 | '/', 184 | { preHandler: [fastify.guard.role('admin', ['author'])] }, 185 | (req, reply) => { 186 | reply.send() 187 | } 188 | ) 189 | 190 | fastify.inject( 191 | { method: 'GET', url: '/' }, 192 | // eslint-disable-next-line 193 | (err, res) => { 194 | // eslint-disable-next-line 195 | expect(res.payload).toBe('') 196 | done() 197 | 198 | fastify.close() 199 | } 200 | ) 201 | }) 202 | }) 203 | 204 | // eslint-disable-next-line 205 | test('insufficient role permission (check OR case by providing two roles as arguments)', done => { 206 | generateServer() 207 | .then(fastify => { 208 | fastify.get( 209 | '/', 210 | { preHandler: [fastify.guard.role('author', ['ceo'])] }, 211 | (req, reply) => { 212 | reply.send() 213 | } 214 | ) 215 | 216 | fastify.inject( 217 | { method: 'GET', url: '/' }, 218 | // eslint-disable-next-line 219 | (err, res) => { 220 | // eslint-disable-next-line 221 | expect(res.statusCode).toBe(403) 222 | done() 223 | 224 | fastify.close() 225 | } 226 | ) 227 | }) 228 | }) 229 | 230 | // eslint-disable-next-line 231 | test('sufficient scope permission (check OR case by providing two scopes as arguments)', done => { 232 | generateServer() 233 | .then(fastify => { 234 | fastify.get( 235 | '/', 236 | { preHandler: [fastify.guard.scope('email', ['user:read'])] }, 237 | (req, reply) => { 238 | reply.send() 239 | } 240 | ) 241 | 242 | fastify.inject( 243 | { method: 'GET', url: '/' }, 244 | // eslint-disable-next-line 245 | (err, res) => { 246 | // eslint-disable-next-line 247 | expect(res.payload).toBe('') 248 | done() 249 | 250 | fastify.close() 251 | } 252 | ) 253 | }) 254 | }) 255 | 256 | // eslint-disable-next-line 257 | test('insufficient scope permission (check OR case by providing two scopes as arguments)', done => { 258 | generateServer() 259 | .then(fastify => { 260 | fastify.get( 261 | '/', 262 | { preHandler: [fastify.guard.scope('user:read', ['user:write'])] }, 263 | (req, reply) => { 264 | reply.send() 265 | } 266 | ) 267 | 268 | fastify.inject( 269 | { method: 'GET', url: '/' }, 270 | // eslint-disable-next-line 271 | (err, res) => { 272 | // eslint-disable-next-line 273 | expect(res.statusCode).toBe(403) 274 | done() 275 | 276 | fastify.close() 277 | } 278 | ) 279 | }) 280 | }) 281 | 282 | // eslint-disable-next-line 283 | test('sufficient role permission (only string as the argument)', done => { 284 | generateServer() 285 | .then(fastify => { 286 | fastify.get( 287 | '/', 288 | { preHandler: [fastify.guard.role('admin')] }, 289 | (req, reply) => { 290 | reply.send() 291 | } 292 | ) 293 | 294 | fastify.inject( 295 | { method: 'GET', url: '/' }, 296 | // eslint-disable-next-line 297 | (err, res) => { 298 | // eslint-disable-next-line 299 | expect(res.payload).toBe('') 300 | done() 301 | 302 | fastify.close() 303 | } 304 | ) 305 | }) 306 | }) 307 | 308 | // eslint-disable-next-line 309 | test('insufficient role permission (only string as the argument)', done => { 310 | generateServer() 311 | .then(fastify => { 312 | fastify.get( 313 | '/', 314 | { preHandler: [fastify.guard.role('author')] }, 315 | (req, reply) => { 316 | reply.send() 317 | } 318 | ) 319 | 320 | fastify.inject( 321 | { method: 'GET', url: '/' }, 322 | // eslint-disable-next-line 323 | (err, res) => { 324 | // eslint-disable-next-line 325 | expect(res.statusCode).toBe(403) 326 | done() 327 | 328 | fastify.close() 329 | } 330 | ) 331 | }) 332 | }) 333 | 334 | // eslint-disable-next-line 335 | test('sufficient scope permission (only string as the argument)', done => { 336 | generateServer() 337 | .then(fastify => { 338 | fastify.get( 339 | '/', 340 | { preHandler: [fastify.guard.scope('email')] }, 341 | (req, reply) => { 342 | reply.send() 343 | } 344 | ) 345 | 346 | fastify.inject( 347 | { method: 'GET', url: '/' }, 348 | // eslint-disable-next-line 349 | (err, res) => { 350 | // eslint-disable-next-line 351 | expect(res.payload).toBe('') 352 | done() 353 | 354 | fastify.close() 355 | } 356 | ) 357 | }) 358 | }) 359 | 360 | // eslint-disable-next-line 361 | test('insufficient scope permission (only string as the argument)', done => { 362 | generateServer() 363 | .then(fastify => { 364 | fastify.get( 365 | '/', 366 | { preHandler: [fastify.guard.scope('user:read')] }, 367 | (req, reply) => { 368 | reply.send() 369 | } 370 | ) 371 | 372 | fastify.inject( 373 | { method: 'GET', url: '/' }, 374 | // eslint-disable-next-line 375 | (err, res) => { 376 | // eslint-disable-next-line 377 | expect(res.statusCode).toBe(403) 378 | done() 379 | 380 | fastify.close() 381 | } 382 | ) 383 | }) 384 | }) 385 | 386 | // eslint-disable-next-line 387 | test('sufficient role permission', done => { 388 | generateServer() 389 | .then(fastify => { 390 | fastify.get( 391 | '/', 392 | { preHandler: [fastify.guard.role(['admin'])] }, 393 | (req, reply) => { 394 | reply.send() 395 | } 396 | ) 397 | 398 | fastify.inject( 399 | { method: 'GET', url: '/' }, 400 | // eslint-disable-next-line 401 | (err, res) => { 402 | // eslint-disable-next-line 403 | expect(res.payload).toBe('') 404 | done() 405 | 406 | fastify.close() 407 | } 408 | ) 409 | }) 410 | }) 411 | 412 | // eslint-disable-next-line 413 | test('insufficient role permission', done => { 414 | generateServer() 415 | .then(fastify => { 416 | fastify.get( 417 | '/', 418 | { preHandler: [fastify.guard.role(['author'])] }, 419 | (req, reply) => { 420 | reply.send() 421 | } 422 | ) 423 | 424 | fastify.inject( 425 | { method: 'GET', url: '/' }, 426 | // eslint-disable-next-line 427 | (err, res) => { 428 | // eslint-disable-next-line 429 | expect(res.statusCode).toBe(403) 430 | done() 431 | 432 | fastify.close() 433 | } 434 | ) 435 | }) 436 | }) 437 | 438 | // eslint-disable-next-line 439 | test('sufficient scope permission', done => { 440 | generateServer() 441 | .then(fastify => { 442 | fastify.get( 443 | '/', 444 | { preHandler: [fastify.guard.scope(['email'])] }, 445 | (req, reply) => { 446 | reply.send() 447 | } 448 | ) 449 | 450 | fastify.inject( 451 | { method: 'GET', url: '/' }, 452 | // eslint-disable-next-line 453 | (err, res) => { 454 | // eslint-disable-next-line 455 | expect(res.payload).toBe('') 456 | done() 457 | 458 | fastify.close() 459 | } 460 | ) 461 | }) 462 | }) 463 | 464 | // eslint-disable-next-line 465 | test('insufficient scope permission', done => { 466 | generateServer() 467 | .then(fastify => { 468 | fastify.get( 469 | '/', 470 | { preHandler: [fastify.guard.scope(['user:read'])] }, 471 | (req, reply) => { 472 | reply.send() 473 | } 474 | ) 475 | 476 | fastify.inject( 477 | { method: 'GET', url: '/' }, 478 | // eslint-disable-next-line 479 | (err, res) => { 480 | // eslint-disable-next-line 481 | expect(res.statusCode).toBe(403) 482 | done() 483 | 484 | fastify.close() 485 | } 486 | ) 487 | }) 488 | }) 489 | 490 | // eslint-disable-next-line 491 | test('sufficient role and scope permissions', done => { 492 | generateServer() 493 | .then(fastify => { 494 | fastify.get( 495 | '/', 496 | { 497 | preHandler: [ 498 | fastify.guard.role(['admin']), 499 | fastify.guard.scope(['email']) 500 | ] 501 | }, 502 | (req, reply) => { 503 | reply.send() 504 | } 505 | ) 506 | 507 | fastify.inject( 508 | { method: 'GET', url: '/' }, 509 | // eslint-disable-next-line 510 | (err, res) => { 511 | // eslint-disable-next-line 512 | expect(res.payload).toBe('') 513 | done() 514 | 515 | fastify.close() 516 | } 517 | ) 518 | }) 519 | }) 520 | 521 | // eslint-disable-next-line 522 | test('insufficient role and scope permissions', done => { 523 | generateServer() 524 | .then(fastify => { 525 | fastify.get( 526 | '/', 527 | { 528 | preHandler: [ 529 | fastify.guard.role(['author']), 530 | fastify.guard.scope(['user:read']) 531 | ] 532 | }, 533 | (req, reply) => { 534 | reply.send() 535 | } 536 | ) 537 | 538 | fastify.inject( 539 | { method: 'GET', url: '/' }, 540 | // eslint-disable-next-line 541 | (err, res) => { 542 | // eslint-disable-next-line 543 | expect(res.statusCode).toBe(403) 544 | done() 545 | 546 | fastify.close() 547 | } 548 | ) 549 | }) 550 | }) 551 | 552 | // eslint-disable-next-line 553 | test('sufficient role and insufficient scope permissions', done => { 554 | generateServer() 555 | .then(fastify => { 556 | fastify.get( 557 | '/', 558 | { 559 | preHandler: [ 560 | fastify.guard.role(['admin']), 561 | fastify.guard.scope(['user:read']) 562 | ] 563 | }, 564 | (req, reply) => { 565 | reply.send() 566 | } 567 | ) 568 | 569 | fastify.inject( 570 | { method: 'GET', url: '/' }, 571 | // eslint-disable-next-line 572 | (err, res) => { 573 | // eslint-disable-next-line 574 | expect(res.statusCode).toBe(403) 575 | done() 576 | 577 | fastify.close() 578 | } 579 | ) 580 | }) 581 | }) 582 | 583 | // eslint-disable-next-line 584 | test('insufficient role and sufficient scope permissions', done => { 585 | generateServer() 586 | .then(fastify => { 587 | fastify.get( 588 | '/', 589 | { 590 | preHandler: [ 591 | fastify.guard.role(['author']), 592 | fastify.guard.scope(['email']) 593 | ] 594 | }, 595 | (req, reply) => { 596 | reply.send() 597 | } 598 | ) 599 | 600 | fastify.inject( 601 | { method: 'GET', url: '/' }, 602 | // eslint-disable-next-line 603 | (err, res) => { 604 | // eslint-disable-next-line 605 | expect(res.statusCode).toBe(403) 606 | done() 607 | 608 | fastify.close() 609 | } 610 | ) 611 | }) 612 | }) 613 | 614 | // eslint-disable-next-line 615 | test('wrong argument error', done => { 616 | generateServer() 617 | .then(fastify => { 618 | fastify.get( 619 | '/', 620 | { preHandler: [fastify.guard.role(true)] }, 621 | (req, reply) => { 622 | reply.send() 623 | } 624 | ) 625 | 626 | fastify.inject( 627 | { method: 'GET', url: '/' }, 628 | // eslint-disable-next-line 629 | (err, res) => { 630 | // eslint-disable-next-line 631 | expect(res.statusCode).toBe(500) 632 | done() 633 | 634 | fastify.close() 635 | } 636 | ) 637 | }) 638 | }) 639 | 640 | // eslint-disable-next-line 641 | test('custom error handler (sufficient case)', done => { 642 | generateServer({ 643 | errorHandler: (result, req, reply) => { 644 | return reply.send('custom error handler works!') 645 | } 646 | }) 647 | .then(fastify => { 648 | fastify.get( 649 | '/', 650 | { preHandler: [fastify.guard.role(['admin'])] }, 651 | (req, reply) => { 652 | reply.send() 653 | } 654 | ) 655 | 656 | fastify.inject( 657 | { method: 'GET', url: '/' }, 658 | // eslint-disable-next-line 659 | (err, res) => { 660 | // eslint-disable-next-line 661 | expect(res.payload).toBe('') 662 | done() 663 | 664 | fastify.close() 665 | } 666 | ) 667 | }) 668 | }) 669 | 670 | // eslint-disable-next-line 671 | test('custom error handler (insufficient case)', done => { 672 | generateServer({ 673 | errorHandler: (result, req, reply) => { 674 | return reply.send('custom error handler works!') 675 | } 676 | }) 677 | .then(fastify => { 678 | fastify.get( 679 | '/', 680 | { preHandler: [fastify.guard.scope(['user:read'])] }, 681 | (req, reply) => { 682 | reply.send() 683 | } 684 | ) 685 | 686 | fastify.inject( 687 | { method: 'GET', url: '/' }, 688 | // eslint-disable-next-line 689 | (err, res) => { 690 | // eslint-disable-next-line 691 | expect(res.payload).toBe('custom error handler works!') 692 | done() 693 | 694 | fastify.close() 695 | } 696 | ) 697 | }) 698 | }) 699 | 700 | // eslint-disable-next-line 701 | test('OIDC style hasScope check', done => { 702 | generateServer({ 703 | scopeProperty: 'scp' 704 | }) 705 | .then(fastify => { 706 | fastify.get('/', (req, reply) => { 707 | const isOk = fastify.guard.hasScope(req, 'profile') 708 | reply.send(isOk) 709 | }) 710 | 711 | fastify.inject( 712 | { method: 'GET', url: '/' }, 713 | // eslint-disable-next-line 714 | (err, res) => { 715 | // eslint-disable-next-line 716 | expect(res.payload).toBe('true') 717 | done() 718 | 719 | fastify.close() 720 | } 721 | ) 722 | }) 723 | }) 724 | 725 | // eslint-disable-next-line 726 | test('OIDC style scope permission (check OR case by providing two scopes as arguments)', done => { 727 | generateServer({ 728 | scopeProperty: 'scp' 729 | }) 730 | .then(fastify => { 731 | fastify.get( 732 | '/', 733 | { preHandler: [fastify.guard.scope('email', ['user:read'])] }, 734 | (req, reply) => { 735 | reply.send() 736 | } 737 | ) 738 | 739 | fastify.inject( 740 | { method: 'GET', url: '/' }, 741 | // eslint-disable-next-line 742 | (err, res) => { 743 | // eslint-disable-next-line 744 | expect(res.payload).toBe('') 745 | done() 746 | 747 | fastify.close() 748 | } 749 | ) 750 | }) 751 | }) 752 | 753 | // eslint-disable-next-line 754 | test('OIDC style hasRole check', done => { 755 | generateServer({ 756 | roleProperty: 'rl' 757 | }) 758 | .then(fastify => { 759 | fastify.get('/', (req, reply) => { 760 | const isOk = fastify.guard.hasRole(req, 'editor') 761 | reply.send(isOk) 762 | }) 763 | 764 | fastify.inject( 765 | { method: 'GET', url: '/' }, 766 | // eslint-disable-next-line 767 | (err, res) => { 768 | // eslint-disable-next-line 769 | expect(res.payload).toBe('true') 770 | done() 771 | 772 | fastify.close() 773 | } 774 | ) 775 | }) 776 | }) 777 | 778 | // eslint-disable-next-line 779 | test('OIDC style role permission (check OR case by providing two roles as arguments)', done => { 780 | generateServer({ 781 | roleProperty: 'rl' 782 | }) 783 | .then(fastify => { 784 | fastify.get( 785 | '/', 786 | { preHandler: [fastify.guard.role('cto', 'admin')] }, 787 | (req, reply) => { 788 | reply.send() 789 | } 790 | ) 791 | 792 | fastify.inject( 793 | { method: 'GET', url: '/' }, 794 | // eslint-disable-next-line 795 | (err, res) => { 796 | // eslint-disable-next-line 797 | expect(res.payload).toBe('') 798 | done() 799 | 800 | fastify.close() 801 | } 802 | ) 803 | }) 804 | }) 805 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import createHttpError from 'http-errors' 2 | import { 3 | FastifyPluginCallback, 4 | FastifyReply, 5 | FastifyRequest, 6 | preHandlerHookHandler 7 | } from 'fastify' 8 | 9 | interface FastifyGuard { 10 | hasRole( 11 | request: FastifyRequest, 12 | role: string 13 | ): boolean | createHttpError.HttpError 14 | hasScope( 15 | request: FastifyRequest, 16 | scope: string 17 | ): boolean | createHttpError.HttpError 18 | role(...roles: string[]): preHandlerHookHandler 19 | role(roles: string[]): preHandlerHookHandler 20 | scope(...scopes: string[]): preHandlerHookHandler 21 | scope(scopes: string[]): preHandlerHookHandler 22 | } 23 | 24 | declare module 'fastify' { 25 | interface FastifyInstance { 26 | guard: FastifyGuard 27 | } 28 | } 29 | 30 | declare const fastifyGuard: FastifyPluginCallback<{ 31 | errorHandler?( 32 | result: createHttpError.HttpError, 33 | request: FastifyRequest, 34 | reply: FastifyReply 35 | ): void 36 | requestProperty?: string 37 | roleProperty?: string 38 | scopeProperty?: string 39 | }> 40 | 41 | export default fastifyGuard 42 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd' 2 | import createHttpError from 'http-errors' 3 | 4 | import Fastify, { 5 | FastifyReply, 6 | FastifyRequest, 7 | preHandlerHookHandler 8 | } from 'fastify' 9 | import fastifyGuard from '.' 10 | 11 | const fastify = Fastify() 12 | 13 | fastify.register(fastifyGuard, { 14 | errorHandler: (result, req, reply) => reply.send('string'), 15 | requestProperty: 'string', 16 | roleProperty: 'string', 17 | scopeProperty: 'string' 18 | }) 19 | 20 | ;(request: FastifyRequest, reply: FastifyReply) => { 21 | expectType( 22 | fastify.guard.hasRole(request, 'user') 23 | ) 24 | 25 | expectType( 26 | fastify.guard.hasScope(request, 'read') 27 | ) 28 | } 29 | 30 | expectType(fastify.guard.role('ceo')) 31 | expectType(fastify.guard.role('ceo', 'cto')) 32 | expectType(fastify.guard.role(['string'])) 33 | expectType(fastify.guard.scope('profile')) 34 | expectType(fastify.guard.scope('profile', 'blog')) 35 | expectType(fastify.guard.scope(['string'])) 36 | --------------------------------------------------------------------------------