├── .babelrc.js ├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ ├── hasRole.test.ts.snap │ └── isAuthenticated.test.ts.snap ├── hasRole.test.ts └── isAuthenticated.test.ts ├── example ├── dev.js ├── index.ts └── schema.ts ├── generateToken.js ├── package.json ├── src ├── hasRole.ts ├── index.ts ├── isAuthenticated.ts ├── typings │ ├── index.d.ts │ └── mock-express-request │ │ └── index.d.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/env', '@babel/preset-typescript'], 3 | plugins: [ 4 | '@babel/plugin-transform-regenerator', 5 | '@babel/proposal-class-properties', 6 | '@babel/proposal-object-rest-spread', 7 | ], 8 | env: { 9 | testing: { 10 | presets: [['@babel/env', { modules: false }], '@babel/preset-typescript'], 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | test-defaults: &test-defaults 4 | working_directory: ~/graphql-directive-auth 5 | steps: 6 | - attach_workspace: 7 | at: ~/graphql-directive-auth 8 | - run: | 9 | yarn lint 10 | yarn test 11 | 12 | jobs: 13 | install-dependencies: 14 | working_directory: ~/graphql-directive-auth 15 | docker: 16 | - image: circleci/node 17 | steps: 18 | - checkout 19 | - attach_workspace: 20 | at: ~/graphql-directive-auth 21 | - restore_cache: 22 | keys: 23 | - dependencies-{{ checksum "yarn.lock" }} 24 | - dependencies- 25 | - run: yarn install --frozen-lockfile 26 | - save_cache: 27 | key: dependencies-{{ checksum "yarn.lock" }} 28 | paths: node_modules 29 | - persist_to_workspace: 30 | root: . 31 | paths: . 32 | 33 | lint-and-test-node: 34 | <<: *test-defaults 35 | docker: 36 | - image: circleci/node 37 | 38 | publish: 39 | working_directory: ~/graphql-directive-auth 40 | docker: 41 | - image: circleci/node 42 | steps: 43 | - attach_workspace: 44 | at: ~/graphql-directive-auth 45 | - run: | 46 | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 47 | npm publish 48 | 49 | workflows: 50 | version: 2 51 | build-and-test: 52 | jobs: 53 | - install-dependencies 54 | - lint-and-test-node: 55 | requires: 56 | - install-dependencies 57 | - publish: 58 | requires: 59 | - install-dependencies 60 | filters: 61 | tags: 62 | only: /v\d+\.\d+\.\d+.*/ 63 | branches: 64 | ignore: /.*/ 65 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'import/no-extraneous-dependencies': 0, 4 | 'no-param-reassign': 0, 5 | }, 6 | extends: 'callstack-io', 7 | env: { 8 | jest: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional eslint cache 44 | .eslintcache 45 | 46 | # Optional REPL history 47 | .node_repl_history 48 | 49 | # Output of 'npm pack' 50 | *.tgz 51 | 52 | # Yarn Integrity file 53 | .yarn-integrity 54 | 55 | # dotenv environment variables file 56 | .env 57 | 58 | dist/ 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Luke Czyszczonik - 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 | # graphql-directive-auth 2 | 3 | [![Version][version-badge]][package] 4 | [![downloads][downloads-badge]][npmtrends] 5 | [![PRs Welcome][prs-badge]][prs] 6 | [![MIT License][license-badge]][build] 7 | 8 | # Introduction 9 | 10 | The `graphql-directive-auth` was created to help with common authentication tasks that is faced in almost every API. 11 | 12 | # Table of Contents 13 | 14 | - [graphql-directive-auth](#graphql-directive-auth) 15 | - [Introduction](#introduction) 16 | - [Table of Contents](#table-of-contents) 17 | - [Installation](#installation) 18 | - [Usage](#usage) 19 | - [Default](#default) 20 | - [What `default` means, and what I **need to do**?](#what-default-means-and-what-i-need-to-do) 21 | - [Example:](#example) 22 | - [Custom behaviour of authentication functions](#custom-behaviour-of-authentication-functions) 23 | - [Custom check role function](#custom-check-role-function) 24 | - [How to create your own function](#how-to-create-your-own-function) 25 | - [Directive Parameters](#directive-parameters) 26 | - [Contributing](#contributing) 27 | - [LICENSE](#license) 28 | 29 | # Installation 30 | 31 | ``` 32 | yarn add graphql-directive-auth 33 | ``` 34 | 35 | # Usage 36 | 37 | We are able to use directives in two different way: 38 | 39 | ## Default 40 | 41 | To use the default directive behaviour, you need to set `APP_SECRET` environment variable, and that's all. 42 | 43 | ### What `default` means, and what do I **need to do**? 44 | 45 | - `@isAuthenticated` - Just after you set environment variables, you need to have a valid JWT token and send it by `Authorization` in the HTTP headers. That's all, the directive will check your token and throw an error if the token is invalid or expired. 46 | - `@hasRole` - Checks roles of an authenticated user. To use it correctly, inside your JWT token you should have the `role` property with the correct role. If the user role doesn't match with the provided role, then directive will throw an error. 47 | 48 | > `@hasRole` before checking role is doing authentication to get roles from JWT token. 49 | 50 | ### Example: 51 | 52 | ```js 53 | import { AuthDirective } from 'graphql-directive-auth'; 54 | // or 55 | const AuthDirective = require('graphql-directive-auth').AuthDirective; 56 | 57 | // set environment variable, but in better way ;) 58 | process.env.APP_SECRET = 'your_secret_key'; 59 | 60 | const schema = makeExecutableSchema({ 61 | typeDefs, 62 | resolvers, 63 | schemaDirectives: { 64 | // to use @hasRole and @isAuthenticated directives 65 | ...AuthDirective(), 66 | // custom name for @isAuthenticated 67 | auth: AuthDirective().isAuthenticated, 68 | // custom name for @hasRole 69 | role: AuthDirective().hasRole, 70 | }, 71 | }); 72 | ``` 73 | 74 | ## Custom behaviour of authentication functions 75 | 76 | If you need custom Authentication you can pass your authentication function to the main `AuthDirective` functions. Your authentication function should return an object which will be available via `context.auth`. 77 | 78 | Authentication function signature: 79 | 80 | ```js 81 | context => { 82 | // your logic here 83 | 84 | // you should return an object 85 | // this object will be passed inside your resolver 86 | // it is available inside context via auth property 87 | return { 88 | user: { 89 | id: 'your_user_id', 90 | }, 91 | }; 92 | }; 93 | ``` 94 | 95 | usage: 96 | 97 | ```js 98 | import { AuthDirective } from 'graphql-directive-auth'; 99 | // or 100 | const AuthDirectives = require('graphql-directive-auth').AuthDirective; 101 | 102 | const customAuth = AuthDirectives({ 103 | authenticateFunc: authenticateCustomFunc, 104 | checkRoleFunc: checkRoleCustomFunc 105 | }); 106 | 107 | const schema = makeExecutableSchema({ 108 | typeDefs, 109 | resolvers, 110 | schemaDirectives: { 111 | // to use @hasRole and @isAuthenticated directives 112 | ...customAuth, 113 | // custom name for @isAuthenticated 114 | auth: customAuth().isAuthenticated, 115 | // custom name for @hasRole 116 | role: customAuth().hasRole, 117 | }, 118 | ``` 119 | 120 | resolver: 121 | 122 | ```js 123 | export default { 124 | Query: { 125 | me() (root, args, ctx){ 126 | const userId = ctx.auth.user.id; // your_user_id 127 | }, 128 | }, 129 | }; 130 | ``` 131 | 132 | ## Custom check role function 133 | 134 | Same as with the authenticate function, you can add your own logic to checking roles. Here is an example of implementation: 135 | 136 | ```js 137 | import { AuthenticationError } from 'apollo-server'; 138 | import jwt from 'jsonwebtoken'; 139 | import { jwtSecret } from '../config'; 140 | 141 | export default (ctx, value) => { 142 | const authorization = 143 | ctx.request && ctx.request.headers && ctx.request.headers.authorization; 144 | 145 | if (!authorization) { 146 | throw new AuthenticationError('Unauthorized access!'); 147 | } 148 | 149 | const token = authorization.replace('Bearer ', ''); 150 | 151 | const decodedToken = jwt.verify(token, jwtSecret); 152 | 153 | const mandatoryRoles = value.split(',').map((s) => s.trim()); 154 | 155 | if (decodedToken && decodedToken.user && decodedToken.user.roles) { 156 | const { roles } = decodedToken.user; 157 | const rolesIntersection = roles.filter((role) => 158 | mandatoryRoles.includes(role), 159 | ); 160 | 161 | if (rolesIntersection.length === 0) { 162 | throw new AuthenticationError('Invalid role!'); 163 | } 164 | 165 | return rolesIntersection; 166 | } 167 | 168 | throw new AuthenticationError('Invalid token!'); 169 | }; 170 | ``` 171 | 172 | ### How to create your own function 173 | 174 | - Function accepts two parameters, one is the context and the second is the value from the directive 175 | - To reject an access to the particular field, you need to throw an Error that will be caught by the directive and returned if required. 176 | - Function doesn't need to return anything special 177 | 178 | # Directive Parameters 179 | 180 | - '@isAuthenticated' - checks if user is authenticated 181 | - '@hasRole(role: "user, admin")' - checks if user is authenticated and has the specified roles 182 | 183 | > if you use [`graphql-import`](https://github.com/prismagraphql/graphql-import) then you need to add this definition on top of the schema: 184 | 185 | ```graphql 186 | directive @isAuthenticated on FIELD | FIELD_DEFINITION 187 | directive @hasRole(role: String) on FIELD | FIELD_DEFINITION 188 | ``` 189 | 190 | ## Contributing 191 | 192 | I would love to see your contribution. ❤️ 193 | 194 | For local development (and testing), all you have to do is to run `yarn` and then `yarn dev`. This will start the Apollo server and you are ready to contribute :tada: 195 | 196 | Run yarn test (try `--watch` flag) for unit tests (we are using Jest) 197 | 198 | # LICENSE 199 | 200 | The MIT License (MIT) 2018 - Luke Czyszczonik - 201 | 202 | [npm]: https://www.npmjs.com/ 203 | [node]: https://nodejs.org 204 | [build-badge]: https://img.shields.io/travis/graphql-community/graphql-directive-auth.svg?style=flat-square 205 | [build]: https://travis-ci.org/graphql-community/graphql-directive-auth 206 | [coverage-badge]: https://img.shields.io/codecov/c/github/graphql-community/graphql-directive-auth.svg?style=flat-square 207 | [coverage]: https://codecov.io/github/graphql-community/graphql-directive-auth 208 | [version-badge]: https://img.shields.io/npm/v/graphql-directive-auth.svg?style=flat-square 209 | [package]: https://www.npmjs.com/package/graphql-directive-auth 210 | [downloads-badge]: https://img.shields.io/npm/dm/graphql-directive-auth.svg?style=flat-square 211 | [npmtrends]: http://www.npmtrends.com/graphql-directive-auth 212 | [license-badge]: https://img.shields.io/npm/l/graphql-directive-auth.svg?style=flat-square 213 | [license]: https://github.com/graphql-community/graphql-directive-auth/blob/master/LICENSE 214 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 215 | [prs]: http://makeapullrequest.com 216 | [donate-badge]: https://img.shields.io/badge/$-support-green.svg?style=flat-square 217 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 218 | [coc]: https://github.com/graphql-community/graphql-directive-auth/blob/master/CODE_OF_CONDUCT.md 219 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/hasRole.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getDirectiveDeclaration should be defined 1`] = ` 4 | Object { 5 | "args": Object { 6 | "role": Object { 7 | "astNode": undefined, 8 | "defaultValue": undefined, 9 | "description": undefined, 10 | "extensions": undefined, 11 | "type": "String", 12 | }, 13 | }, 14 | "astNode": undefined, 15 | "description": undefined, 16 | "extensions": undefined, 17 | "isRepeatable": false, 18 | "locations": Array [ 19 | "FIELD_DEFINITION", 20 | ], 21 | "name": "hasRole", 22 | } 23 | `; 24 | 25 | exports[`if return null for a field when does not have permissions 1`] = ` 26 | Object { 27 | "data": Object { 28 | "field": Object { 29 | "private": null, 30 | "public": "public_exists", 31 | }, 32 | }, 33 | } 34 | `; 35 | 36 | exports[`if role are correct 1`] = ` 37 | Object { 38 | "data": Object { 39 | "you": Any, 40 | }, 41 | } 42 | `; 43 | 44 | exports[`if throw an Error when user does not have correct roles 1`] = ` 45 | Object { 46 | "data": Object { 47 | "together": null, 48 | }, 49 | "errors": Anything, 50 | } 51 | `; 52 | 53 | exports[`if throw error if no role inside token payload 1`] = ` 54 | Object { 55 | "data": Object { 56 | "you": null, 57 | }, 58 | "errors": Anything, 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/isAuthenticated.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getDirectiveDeclaration should be defined 1`] = ` 4 | Object { 5 | "args": Object {}, 6 | "astNode": undefined, 7 | "description": undefined, 8 | "extensions": undefined, 9 | "isRepeatable": false, 10 | "locations": Array [ 11 | "FIELD_DEFINITION", 12 | ], 13 | "name": "isAuthenticated", 14 | } 15 | `; 16 | 17 | exports[`if call resolver if Authorization header is set to correct value 1`] = ` 18 | Object { 19 | "data": Object { 20 | "me": Object { 21 | "id": Any, 22 | "isAdmin": Any, 23 | "username": Any, 24 | }, 25 | }, 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /__tests__/hasRole.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import { AuthDirective } from '../src/index'; 4 | import schema from '../example/schema'; 5 | 6 | beforeAll(() => { 7 | process.env.APP_SECRET = '123'; 8 | }); 9 | 10 | test('getDirectiveDeclaration should be defined', () => { 11 | expect(AuthDirective().hasRole.getDirectiveDeclaration().toConfig()).toMatchSnapshot(); 12 | }); 13 | 14 | test('if throw error if no role inside token payload', () => 15 | graphql( 16 | schema, 17 | ` 18 | query { 19 | you 20 | } 21 | `, 22 | {}, 23 | { 24 | req: new MockExpressRequest({ 25 | headers: { 26 | Authorization: 27 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbG8iOjEyMywiaWF0IjoxNTMyNzI3MDMzfQ.nSGh5q9YXZd-pWYagOxD2f-9hdNPw08e4MnKASgn4wY', 28 | }, 29 | }), 30 | } 31 | ).then(response => { 32 | expect(response.errors[0].message).toEqual( 33 | 'Invalid token payload, missing role property inside!' 34 | ); 35 | expect(response.data.you).toBeNull(); 36 | expect(response).toMatchSnapshot({ 37 | data: { 38 | you: null, 39 | }, 40 | errors: expect.anything(), 41 | }); 42 | })); 43 | 44 | test('if role are correct', () => 45 | graphql( 46 | schema, 47 | ` 48 | query { 49 | you 50 | } 51 | `, 52 | {}, 53 | { 54 | req: new MockExpressRequest({ 55 | headers: { 56 | Authorization: 57 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXJfaWQiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTUzMzA0ODk5N30.DISf4XuHkVo7YPXjY2VQWXgZge-c_ejLzsiBql2mXIs', 58 | }, 59 | }), 60 | } 61 | ).then(response => { 62 | expect(response.errors).toBeUndefined(); 63 | expect(response).toMatchSnapshot({ 64 | data: { 65 | you: expect.any(String), 66 | }, 67 | }); 68 | })); 69 | 70 | test('if throw an Error when user does not have correct roles', () => 71 | graphql( 72 | schema, 73 | ` 74 | query { 75 | together 76 | } 77 | `, 78 | {}, 79 | { 80 | req: new MockExpressRequest({ 81 | headers: { 82 | Authorization: 83 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXJfaWQiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTUzMzA0ODk5N30.DISf4XuHkVo7YPXjY2VQWXgZge-c_ejLzsiBql2mXIs', 84 | }, 85 | }), 86 | } 87 | ).then(response => { 88 | expect(response.errors[0].message).toEqual( 89 | 'Must have role: MALINA, you have role: USER' 90 | ); 91 | expect(response.data.together).toBeNull(); 92 | expect(response).toMatchSnapshot({ 93 | data: { 94 | together: null, 95 | }, 96 | errors: expect.anything(), 97 | }); 98 | })); 99 | 100 | test('if return null for a field when does not have permissions', () => 101 | graphql( 102 | schema, 103 | ` 104 | query { 105 | field { 106 | public 107 | private 108 | } 109 | } 110 | `, 111 | {}, 112 | { 113 | req: new MockExpressRequest({ 114 | headers: { 115 | Authorization: 116 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXJfaWQiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTUzMzA0ODk5N30.DISf4XuHkVo7YPXjY2VQWXgZge-c_ejLzsiBql2mXIs', 117 | }, 118 | }), 119 | } 120 | ).then(response => { 121 | expect(response).toMatchSnapshot({ 122 | data: { 123 | field: { 124 | public: 'public_exists', 125 | private: null, 126 | }, 127 | }, 128 | }); 129 | })); 130 | -------------------------------------------------------------------------------- /__tests__/isAuthenticated.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import { AuthDirective } from '../src/index'; 4 | import schema from '../example/schema'; 5 | 6 | beforeAll(() => { 7 | process.env.APP_SECRET = '123'; 8 | }); 9 | 10 | test('getDirectiveDeclaration should be defined', () => { 11 | expect( 12 | AuthDirective().isAuthenticated.getDirectiveDeclaration().toConfig() 13 | ).toMatchSnapshot(); 14 | }); 15 | 16 | test('if call resolver if Authorization header is set to correct value', () => 17 | graphql( 18 | schema, 19 | ` 20 | query { 21 | me { 22 | id 23 | username 24 | isAdmin 25 | } 26 | } 27 | `, 28 | {}, 29 | { 30 | req: new MockExpressRequest({ 31 | headers: { 32 | Authorization: 33 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbG8iOjEyMywiaWF0IjoxNTMyNzI3MDMzfQ.nSGh5q9YXZd-pWYagOxD2f-9hdNPw08e4MnKASgn4wY', 34 | }, 35 | }), 36 | } 37 | ).then(response => { 38 | expect(response).toMatchSnapshot({ 39 | data: { 40 | me: { 41 | id: expect.any(String), 42 | username: expect.any(String), 43 | isAdmin: expect.any(Boolean), 44 | }, 45 | }, 46 | }); 47 | })); 48 | 49 | test('if throw an Error if Authorization header is not set to correct value', () => 50 | graphql( 51 | schema, 52 | ` 53 | query { 54 | me { 55 | id 56 | username 57 | isAdmin 58 | } 59 | } 60 | `, 61 | {}, 62 | { 63 | req: new MockExpressRequest({ headers: {} }), 64 | } 65 | ).then(response => { 66 | expect(response.errors[0].message).toEqual('Not authorized!'); 67 | expect(response.data.me).toBeNull(); 68 | })); 69 | -------------------------------------------------------------------------------- /example/dev.js: -------------------------------------------------------------------------------- 1 | process.env.APP_SECRET = '123'; 2 | 3 | require('@babel/register')({ extensions: ['.js', '.ts'] }); 4 | require('./index.ts'); 5 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ApolloServer } from 'apollo-server-express'; 3 | import schema from './schema'; 4 | 5 | const PORT = process.env.PORT || 4000; 6 | 7 | const app = express(); 8 | 9 | const server = new ApolloServer({ 10 | schema, 11 | context: ({ req }) => ({ req }), 12 | formatError(err) { 13 | return { 14 | message: err.message, 15 | code: err.originalError && err.originalError.code, 16 | locations: err.locations, 17 | path: err.path, 18 | }; 19 | }, 20 | }); 21 | 22 | server.applyMiddleware({ 23 | app, 24 | }); 25 | 26 | app.listen({ port: PORT }, () => 27 | // eslint-disable-next-line no-console 28 | console.log( 29 | `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}` 30 | ) 31 | ); 32 | -------------------------------------------------------------------------------- /example/schema.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | import { AuthDirective } from '../src/index'; 3 | import { makeExecutableSchema } from 'graphql-tools'; 4 | 5 | const typeDefs = gql` 6 | directive @isAuthenticated on FIELD_DEFINITION 7 | directive @hasRole(role: String) on FIELD_DEFINITION 8 | 9 | type User { 10 | id: ID! 11 | username: String! 12 | isAdmin: Boolean 13 | } 14 | 15 | type Custom { 16 | public: String 17 | private: String @hasRole(role: "MALINA") 18 | } 19 | 20 | type Query { 21 | me: User @isAuthenticated 22 | you: String @hasRole(role: "USER") 23 | together: String @hasRole(role: "MALINA") 24 | field: Custom 25 | } 26 | `; 27 | 28 | const resolvers = { 29 | Query: { 30 | me: () => ({ 31 | id: 'uniqKey1', 32 | username: 'Bond', 33 | isAdmin: true, 34 | }), 35 | you: () => `you are ${Math.random() * 100}`, 36 | together: () => Math.random() * 10, 37 | field: () => ({ public: 'public_exists', private: 'private_exists' }), 38 | }, 39 | }; 40 | 41 | export default makeExecutableSchema({ 42 | typeDefs, 43 | resolvers, 44 | schemaDirectives: { 45 | ...AuthDirective(), 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /generateToken.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const jwt = require('jsonwebtoken'); 3 | 4 | const SECRET = process.env.secret || '123'; 5 | 6 | (function generateToken() { 7 | const token = jwt.sign( 8 | { 9 | id: 'user_id', 10 | role: 'USER', 11 | }, 12 | SECRET 13 | ); 14 | 15 | console.log(`\n${token}\n`); 16 | })(); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-directive-auth", 3 | "version": "0.3.2", 4 | "description": "GraphQL directive auth", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com:graphql-community/graphql-directive-auth.git" 13 | }, 14 | "author": "Czysty ", 15 | "license": "MIT", 16 | "typings": "./dist/index.d.ts", 17 | "scripts": { 18 | "dev": "nodemon example/dev.js", 19 | "test": "jest", 20 | "build": "tsc", 21 | "lint": "eslint .", 22 | "type-check": "tsc --noEmit", 23 | "build:types": "tsc --emitDeclarationOnly", 24 | "token": "node generateToken", 25 | "prepublish": "yarn build" 26 | }, 27 | "peerDependencies": { 28 | "graphql": "^0.13.2", 29 | "graphql-tools": "^3.0.5" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.1.2", 33 | "@babel/core": "^7.1.2", 34 | "@babel/plugin-proposal-class-properties": "^7.1.0", 35 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 36 | "@babel/preset-env": "^7.1.0", 37 | "@babel/preset-typescript": "^7.1.0", 38 | "@babel/register": "^7.0.0", 39 | "@types/jsonwebtoken": "^7.2.8", 40 | "apollo-server-express": "^2.0.0", 41 | "body-parser": "^1.18.3", 42 | "eslint": "^5.2.0", 43 | "eslint-config-callstack-io": "^1.1.2", 44 | "express": "^4.16.4", 45 | "graphql": "^15.0.0", 46 | "graphql-tools": "^5.0.0", 47 | "jest": "^25.4.0", 48 | "mock-express-request": "^0.2.2", 49 | "nock": "^10.0.2", 50 | "nodemon": "^1.18.7", 51 | "ts-jest": "^25.4.0", 52 | "typescript": "^3.1.6" 53 | }, 54 | "dependencies": { 55 | "@babel/polyfill": "^7.0.0", 56 | "jsonwebtoken": "^8.4.0" 57 | }, 58 | "jest": { 59 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.ts?$", 60 | "testURL": "http://localhost", 61 | "moduleDirectories": [ 62 | "node_modules" 63 | ], 64 | "transform": { 65 | "^.+\\.ts?$": "ts-jest" 66 | }, 67 | "globals": { 68 | "APP_SECRET": "123", 69 | "ts-jest": { 70 | "diagnostics": false 71 | } 72 | }, 73 | "moduleFileExtensions": [ 74 | "ts", 75 | "js", 76 | "json" 77 | ], 78 | "modulePathIgnorePatterns": [ 79 | "/dist/" 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/hasRole.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'graphql-tools'; 2 | import { 3 | DirectiveLocation, 4 | GraphQLDirective, 5 | defaultFieldResolver, 6 | GraphQLString, 7 | } from 'graphql'; 8 | import {authFunc, checkRoleFunc, CheckRole, checkRole} from './index'; 9 | 10 | export default (authenticate: authFunc, checkRoleFunc?: checkRoleFunc) => 11 | class HasRole extends SchemaDirectiveVisitor { 12 | static getDirectiveDeclaration(directiveName = 'hasRole') { 13 | return new GraphQLDirective({ 14 | name: directiveName, 15 | locations: [DirectiveLocation.FIELD_DEFINITION], 16 | args: { 17 | role: { type: GraphQLString }, 18 | }, 19 | }); 20 | } 21 | 22 | visitFieldDefinition(field: any) { 23 | const { resolve = defaultFieldResolver } = field; 24 | 25 | const hasResolveFn = field.resolve !== undefined; 26 | 27 | field.resolve = (root: any, args: any, context: any, info: any) => { 28 | const auth = authenticate(context); 29 | const allowedRoles = this.args.role; 30 | 31 | const checkRoleFn = checkRoleFunc || checkRole; 32 | 33 | const newContext = { ...context, auth }; 34 | 35 | try { 36 | checkRoleFn(newContext, allowedRoles); 37 | } catch (error) { 38 | if (!hasResolveFn) { 39 | return null; 40 | } 41 | 42 | throw error; 43 | } 44 | 45 | return resolve.call(this, root, args, { ...newContext }, info); 46 | }; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | import isAuthenticated from './isAuthenticated'; 3 | import hasRole from './hasRole'; 4 | import { authenticate, checkRole } from './utils'; 5 | 6 | export interface CheckRole { 7 | userRole: any; 8 | } 9 | export type authFunc = (any: any) => any; 10 | export type checkRoleFunc = (auth: any, allowedRoles: any) => void; 11 | 12 | export interface Args { 13 | authenticateFunc?: authFunc; 14 | checkRoleFunc?: checkRoleFunc; 15 | } 16 | 17 | const AuthDirective = (args: Args = {}) => { 18 | const auth = args.authenticateFunc || authenticate; 19 | 20 | return { 21 | isAuthenticated: isAuthenticated(auth), 22 | hasRole: hasRole(auth, args.checkRoleFunc), 23 | } as any; 24 | } 25 | export { AuthDirective, authenticate, checkRole } 26 | -------------------------------------------------------------------------------- /src/isAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'graphql-tools'; 2 | import { 3 | DirectiveLocation, 4 | GraphQLDirective, 5 | defaultFieldResolver, 6 | } from 'graphql'; 7 | 8 | export default (authenticate: (ctx: any) => any) => 9 | class isAuthenticated extends SchemaDirectiveVisitor { 10 | static getDirectiveDeclaration(directiveName = 'isAuthenticated') { 11 | return new GraphQLDirective({ 12 | name: directiveName, 13 | locations: [DirectiveLocation.FIELD_DEFINITION], 14 | }); 15 | } 16 | 17 | visitFieldDefinition(field: any) { 18 | const { resolve = defaultFieldResolver } = field; 19 | 20 | field.resolve = (root: any, args: any, context: any, info: any) => { 21 | const auth = authenticate(context); 22 | 23 | return resolve.call(this, root, args, { ...context, auth }, info); 24 | }; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | import { authFunc, checkRoleFunc } from '../index'; 2 | 3 | declare module 'graphql-directive-auth' { 4 | interface AuthDirective { 5 | hasRole: any; 6 | isAuthenticated: any; 7 | } 8 | 9 | interface Args { 10 | authenticateFunc?: authFunc; 11 | checkRoleFunc?: checkRoleFunc; 12 | } 13 | 14 | interface DirectivesInterface { 15 | AuthDirective: AuthDirective; 16 | authenticateFunc?: authFunc; 17 | checkRoleFunc?: checkRoleFunc; 18 | } 19 | 20 | export default function(args?: Args): DirectivesInterface; 21 | } 22 | -------------------------------------------------------------------------------- /src/typings/mock-express-request/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'mock-express-request' { 2 | export default class Mock { 3 | constructor(param: object); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | export class AuthError extends Error { 4 | code: number; 5 | 6 | constructor(message = 'Error occured', code = 400) { 7 | super(message); 8 | 9 | this.code = code; 10 | } 11 | } 12 | 13 | export const authenticate = (context: any) => { 14 | const authorization = context.req.get('Authorization'); 15 | 16 | if (authorization) { 17 | const token = authorization.replace('Bearer ', ''); 18 | 19 | try { 20 | const secret = process.env.APP_SECRET; 21 | 22 | if (!secret) { 23 | throw new Error( 24 | 'Secret not provided, please provide `APP_SECRET` with your token' 25 | ); 26 | } 27 | 28 | return jwt.verify(token, secret); 29 | } catch (e) { 30 | throw new AuthError('Invalid token!', 401); 31 | } 32 | } 33 | 34 | throw new AuthError('Not authorized!', 401); 35 | }; 36 | 37 | export const checkRole = (context: any, requiredRoles: any) => { 38 | const userRole = context.auth.role; 39 | 40 | if (!userRole) { 41 | throw new Error(`Invalid token payload, missing role property inside!`); 42 | } 43 | 44 | const hasNeededRole = requiredRoles 45 | .split(',') 46 | .map((role: any) => role.trim().toLowerCase()) 47 | .includes(userRole.toLowerCase()); 48 | 49 | if (!hasNeededRole) { 50 | throw new Error( 51 | `Must have role: ${requiredRoles}, you have role: ${userRole}` 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "declaration": true, 8 | "outDir": "dist", 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src"] 12 | } 13 | --------------------------------------------------------------------------------