├── .babelrc ├── .circleci └── config.yml ├── .env ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples └── index.js ├── package-lock.json ├── package.json ├── scripts └── wait-for-graphql.sh ├── src ├── errors.js └── index.js └── test ├── helpers └── test-setup.js └── test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | node10tests: 5 | docker: 6 | - image: circleci/node:10-browsers 7 | steps: 8 | - checkout 9 | - run: npm install 10 | - run: 11 | name: Start graphql in background 12 | command: npm run start-test-setup 13 | background: true 14 | - run: ./scripts/wait-for-graphql.sh 15 | - run: npm run test 16 | 17 | workflows: 18 | version: 2 19 | integration_test: 20 | jobs: 21 | - node10tests -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | JWT_SECRET=qwertyuiopasdfghjklzxcvbnm123456 -------------------------------------------------------------------------------- /.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 (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | .idea 61 | 62 | .DS_Store 63 | 64 | dist/ 65 | 66 | node-version 67 | neo4j-version 68 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | .DS_Store 4 | .idea 5 | scripts -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.2.0 4 | 5 | - [Remove the need to verify the token per request](https://github.com/grand-stack/graphql-auth-directives/pull/17) 6 | - [Add token payload to context](https://github.com/grand-stack/graphql-auth-directives/pull/14) 7 | - [Check for token in cookies](https://github.com/grand-stack/graphql-auth-directives/pull/6) 8 | -[Allow verifyAndDecodeToken to accept either req or ctx as argument](https://github.com/grand-stack/graphql-auth-directives/pull/5) 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this file except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-auth-directives 2 | 3 | [![CircleCI](https://circleci.com/gh/grand-stack/graphql-auth-directives.svg?style=svg)](https://circleci.com/gh/grand-stack/graphql-auth-directives) 4 | 5 | Add authentication to your GraphQL API with schema directives. 6 | 7 | ## Schema directives for authorization 8 | 9 | - [ ] `@isAuthenticated` 10 | - [ ] `@hasRole` 11 | - [ ] `@hasScope` 12 | 13 | ## Quick start 14 | 15 | ```sh 16 | npm install --save graphql-auth-directives 17 | ``` 18 | 19 | Then import the schema directives you'd like to use and attach them during your GraphQL schema construction. For example using [neo4j-graphql.js' `makeAugmentedSchema`](https://grandstack.io/docs/neo4j-graphql-js-api.html#makeaugmentedschemaoptions-graphqlschema): 20 | 21 | 22 | ```js 23 | import { IsAuthenticatedDirective, HasRoleDirective, HasScopeDirective } from "graphql-auth-directives"; 24 | 25 | const augmentedSchema = makeAugmentedSchema({ 26 | typeDefs, 27 | schemaDirectives: { 28 | isAuthenticated: IsAuthenticatedDirective, 29 | hasRole: HasRoleDirective, 30 | hasScope: HasScopeDirective 31 | } 32 | }); 33 | ``` 34 | 35 | The `@hasRole`, `@hasScope`, and `@isAuthenticated` directives will now be available for use in your GraphQL schema: 36 | 37 | ``` 38 | type Query { 39 | userById(userId: ID!): User @hasScope(scopes: ["User:Read"]) 40 | itemById(itemId: ID!): Item @hasScope(scopes: ["Item:Read"]) 41 | } 42 | ``` 43 | 44 | Be sure to inject the request headers into the GraphQL resolver context. For example, with Apollo Server: 45 | 46 | ```js 47 | const server = new ApolloServer({ 48 | schema, 49 | context: ({ req }) => { 50 | return req; 51 | } 52 | }); 53 | ``` 54 | 55 | In the case that the token was decoded with no errors the `context.user` will store the payload from the token 56 | 57 | ```js 58 | me: (parent, args, context) => { 59 | console.log(context.user.id); 60 | } 61 | ``` 62 | 63 | A JWT must then be included in each GraphQL request in the Authorization header. For example, with Apollo Client: 64 | 65 | ```js 66 | import { createHttpLink } from 'apollo-link-http'; 67 | import { setContext } from 'apollo-link-context'; 68 | import { InMemoryCache } from 'apollo-cache-inmemory'; 69 | import { ApolloClient } from 'apollo-client'; 70 | 71 | 72 | const httpLink = createHttpLink({ 73 | uri: 74 | }); 75 | 76 | const authLink = setContext((_, { headers }) => { 77 | const token = localStorage.getItem('id_token'); // here we are storing the JWT in localStorage 78 | return { 79 | headers: { 80 | ...headers, 81 | authorization: token ? `Bearer ${token}` : "", 82 | } 83 | } 84 | }); 85 | 86 | const client = new ApolloClient({ 87 | link: authLink.concat(httpLink), 88 | cache: new InMemoryCache() 89 | }); 90 | ``` 91 | 92 | ## Configure 93 | 94 | Configuration is done via environment variables. 95 | 96 | (required) 97 | There are two variables to control how tokens are processed. 98 | If you would like the server to verify the tokens used in a request, you must provide the secret used to encode the token in the `JWT_SECRET` variable. Otherwise you will need to set `JWT_NO_VERIFY` to true. 99 | 100 | ```sh 101 | export JWT_NO_VERIFY=true //Server does not have the secret, but will need to decode tokens 102 | ``` 103 | or 104 | ```sh 105 | export JWT_SECRET=> //Server has the secret and will verify authenticity 106 | ``` 107 | 108 | (optional) 109 | By default `@hasRole` will validate the `roles`, `role`, `Roles`, or `Role` claim (whichever is found first). You can override this by setting `AUTH_DIRECTIVES_ROLE_KEY` environment variable. For example, if your role claim is stored in the JWT like this 110 | 111 | ```sh 112 | "https://grandstack.io/roles": [ 113 | "admin" 114 | ] 115 | ``` 116 | 117 | Set: 118 | 119 | ```sh 120 | export AUTH_DIRECTIVES_ROLE_KEY=https://grandstack.io/roles 121 | ``` 122 | 123 | ## Running Tests Locally 124 | 125 | 1. create ./test/helpers/.env 126 | 2. add relevant values 127 | 3. run the test server 128 | ```sh 129 | npx babel-node test/helpers/test-setup.js 130 | ``` 131 | 4. run the tests 132 | ```sh 133 | npx ava test/*.js 134 | ``` 135 | 136 | 137 | ## Test JWTs 138 | 139 | Scopes: user:CRUD 140 | 141 | ~~~ 142 | key: qwertyuiopasdfghjklzxcvbnm123456 143 | ~~~ 144 | 145 | ~~~ 146 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJHUkFORHN0YWNrIiwiaWF0IjoxNTQ5MTQ1Mjk0LCJleHAiOjE2OTE3ODEzMDcsImF1ZCI6ImdyYW5kc3RhY2suaW8iLCJzdWIiOiJib2JAbG9ibGF3LmNvbSIsIlJvbGUiOiJBRE1JTiIsIlNjb3BlIjpbIlVzZXI6UmVhZCIsIlVzZXI6Q3JlYXRlIiwiVXNlcjpVcGRhdGUiLCJVc2VyOkRlbGV0ZSJdfQ.WJffOec05r8KuzW76asax1iCzv5q4rwRv9kvFyw7c_E 147 | ~~~ 148 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from "apollo-server"; 2 | import { makeExecutableSchema } from "graphql-tools"; 3 | 4 | import { 5 | IsAuthenticatedDirective, 6 | HasRoleDirective, 7 | HasScopeDirective 8 | } from "../src/index"; 9 | 10 | export const typeDefs = ` 11 | 12 | directive @hasScope(scopes: [String]) on OBJECT | FIELD_DEFINITION 13 | directive @hasRole(roles: [Role]) on OBJECT | FIELD_DEFINITION 14 | directive @isAuthenticated on OBJECT | FIELD_DEFINITION 15 | 16 | enum Role { 17 | reader 18 | user 19 | admin 20 | } 21 | 22 | type User { 23 | id: ID! 24 | name: String 25 | } 26 | 27 | type Item { 28 | id: ID! 29 | name: String 30 | } 31 | 32 | type Query { 33 | userById(userId: ID!): User @hasScope(scopes: ["User:Read"]) 34 | itemById(itemId: ID!): Item @hasScope(scopes: ["Item:Read"]) 35 | } 36 | 37 | type Mutation { 38 | createUser(id: ID!, name: String): User @hasScope(scopes: ["User:Create"]) 39 | createItem(id: ID!, name: String): Item @hasScope(scopes: ["Item:Create"]) 40 | 41 | updateUser(id: ID!, name: String): User @hasScope(scopes: ["User:Update"]) 42 | updateItem(id: ID!, name: String): Item @hasScope(scopes: ["Item:Update"]) 43 | 44 | deleteUser(id: ID!): User @hasScope(scopes: ["User:Delete"]) 45 | deleteItem(id: ID!): Item @hasScope(scopes: ["Item:Delete"]) 46 | 47 | addUserItemRelationship(userId: ID!, itemId: ID!): User @hasScope(scopes: ["User:Create", "Item:Create"]) 48 | } 49 | `; 50 | 51 | const resolvers = { 52 | Query: { 53 | userById(object, params, ctx, resolveInfo) { 54 | console.log("userById resolver"); 55 | return { 56 | id: params.userId, 57 | name: "bob" 58 | }; 59 | }, 60 | itemById(object, params, ctx, resolveInfo) { 61 | console.log("itemById resolver"); 62 | return { 63 | id: "123", 64 | name: "bob" 65 | }; 66 | } 67 | }, 68 | Mutation: { 69 | createUser(object, params, ctx, resolveInfo) { 70 | console.log("createUser resolver"); 71 | }, 72 | createItem(object, params, ctx, resolveInfo) { 73 | console.log("createItem resolver"); 74 | }, 75 | updateUser(object, params, ctx, resolveInfo) { 76 | console.log("updateUser resolver"); 77 | }, 78 | updateItem(object, params, ctx, resolveInfo) { 79 | console.log("updateItem resolver"); 80 | }, 81 | deleteUser(object, params, ctx, resolveInfo) { 82 | console.log("deleteUser resolver"); 83 | }, 84 | deleteItem(object, params, ctx, resolveInfo) { 85 | console.log("deleteItem resolver"); 86 | }, 87 | addUserItemRelationship(object, params, ctx, resolveInfo) { 88 | console.log("addUserItemRelationship resolver"); 89 | } 90 | } 91 | }; 92 | 93 | const schema = makeExecutableSchema({ 94 | typeDefs, 95 | resolvers, 96 | schemaDirectives: { 97 | isAuthenticated: IsAuthenticatedDirective, 98 | hasRole: HasRoleDirective, 99 | hasScope: HasScopeDirective 100 | } 101 | }); 102 | 103 | const server = new ApolloServer({ 104 | schema, 105 | context: ({ req }) => { 106 | return req; 107 | } 108 | }); 109 | 110 | server.listen(3000, "0.0.0.0").then(({ url }) => { 111 | console.log(`GraphQL server ready at ${url}`); 112 | }); 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-auth-directives", 3 | "version": "2.2.1", 4 | "description": "Add authorization to your GraphQL API using schema directives.", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "start": "nodemon ./examples/index.js --exec babel-node -e js", 8 | "start-test-setup": "babel-node ./test/helpers/test-setup.js", 9 | "test": "ava test/*.js", 10 | "build": "babel src --out-dir dist", 11 | "precommit": "lint-staged", 12 | "prepublish": "npm run build", 13 | "pretest": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/grand-stack/graphql-auth-directives.git" 18 | }, 19 | "devDependencies": { 20 | "@babel/cli": "^7.12.13", 21 | "@babel/core": "^7.7.2", 22 | "@babel/node": "^7.7.0", 23 | "@babel/preset-env": "^7.7.1", 24 | "apollo-cache-inmemory": "^1.4.2", 25 | "apollo-client": "^2.6.10", 26 | "apollo-link-http": "^1.5.17", 27 | "apollo-server": "^2.16.1", 28 | "ava": "^3.4.0", 29 | "dotenv": "^6.2.0", 30 | "husky": "^1.1.2", 31 | "lint-staged": "^7.3.0", 32 | "nodemon": "^1.18.7", 33 | "prettier": "^1.13.5" 34 | }, 35 | "keywords": [ 36 | "GraphQL", 37 | "authorization", 38 | "neo4j" 39 | ], 40 | "author": "William Lyon", 41 | "license": "Apache-2.0", 42 | "bugs": { 43 | "url": "https://github.com/grand-stack/graphql-auth-directives/issues" 44 | }, 45 | "homepage": "https://github.com/grand-stack/graphql-auth-directives#readme", 46 | "lint-staged": { 47 | "*.{js,json,css}": [ 48 | "prettier --write", 49 | "git add" 50 | ] 51 | }, 52 | "dependencies": { 53 | "apollo-errors": "^1.9.0", 54 | "graphql-tools": "^4.0.7", 55 | "jsonwebtoken": "^8.3.0" 56 | }, 57 | "peerDependencies": { 58 | "graphql": "~14.x || ~15.x" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/wait-for-graphql.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | HTTP_PORT=3000 4 | 5 | echo "Waiting up to 2 minutes for graphql http port ($HTTP_PORT)" 6 | 7 | for i in {1..120}; 8 | do 9 | nc -z localhost $HTTP_PORT 10 | is_up=$? 11 | if [ $is_up -eq 0 ]; then 12 | echo 13 | echo "Successfully started, graphql http available on $HTTP_PORT" 14 | break 15 | fi 16 | sleep 1 17 | echo -n "." 18 | done 19 | echo -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import { createError } from "apollo-errors"; 2 | 3 | const AuthorizationError = createError('AuthorizationError', { 4 | message: 'You are not authorized.' 5 | }); 6 | 7 | module.exports = { AuthorizationError }; 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { AuthorizationError } from "./errors"; 2 | import { IncomingMessage } from "http"; 3 | import * as jwt from "jsonwebtoken"; 4 | import { SchemaDirectiveVisitor } from "graphql-tools"; 5 | import { 6 | DirectiveLocation, 7 | GraphQLDirective, 8 | GraphQLList, 9 | GraphQLString 10 | } from "graphql"; 11 | 12 | const verifyAndDecodeToken = ({ context }) => { 13 | const req = 14 | context instanceof IncomingMessage 15 | ? context 16 | : context.req || context.request; 17 | 18 | if ( 19 | !req || 20 | !req.headers || 21 | (!req.headers.authorization && !req.headers.Authorization) || 22 | (!req && !req.cookies && !req.cookies.token) 23 | ) { 24 | throw new AuthorizationError({ message: "No authorization token." }); 25 | } 26 | 27 | const token = 28 | req.headers.authorization || req.headers.Authorization || req.cookies.token; 29 | try { 30 | const id_token = token.replace("Bearer ", ""); 31 | const { JWT_SECRET, JWT_NO_VERIFY } = process.env; 32 | 33 | if (!JWT_SECRET && JWT_NO_VERIFY) { 34 | return jwt.decode(id_token); 35 | } else { 36 | return jwt.verify(id_token, JWT_SECRET, { 37 | algorithms: ["HS256", "RS256"] 38 | }); 39 | } 40 | } catch (err) { 41 | if (err.name === "TokenExpiredError") { 42 | throw new AuthorizationError({ 43 | message: "Your token is expired" 44 | }); 45 | } else { 46 | throw new AuthorizationError({ 47 | message: "You are not authorized for this resource" 48 | }); 49 | } 50 | } 51 | }; 52 | 53 | export class HasScopeDirective extends SchemaDirectiveVisitor { 54 | static getDirectiveDeclaration(directiveName, schema) { 55 | return new GraphQLDirective({ 56 | name: "hasScope", 57 | locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], 58 | args: { 59 | scopes: { 60 | type: new GraphQLList(GraphQLString), 61 | defaultValue: "none:read" 62 | } 63 | } 64 | }); 65 | } 66 | 67 | // used for example, with Query and Mutation fields 68 | visitFieldDefinition(field) { 69 | const expectedScopes = this.args.scopes; 70 | const next = field.resolve; 71 | 72 | // wrap resolver with auth check 73 | field.resolve = function(result, args, context, info) { 74 | const decoded = verifyAndDecodeToken({ context }); 75 | 76 | const scopes = process.env.AUTH_DIRECTIVES_SCOPE_KEY 77 | ? decoded[process.env.AUTH_DIRECTIVES_SCOPE_KEY] || [] 78 | : decoded["permissions"] || 79 | decoded["Permissions"] || 80 | decoded["Scopes"] || 81 | decoded["scopes"] || 82 | decoded["Scope"] || 83 | decoded["scope"] || 84 | []; 85 | 86 | if (expectedScopes.some(scope => scopes.indexOf(scope) !== -1)) { 87 | return next(result, args, { ...context, user: decoded }, info); 88 | } 89 | 90 | throw new AuthorizationError({ 91 | message: "You are not authorized for this resource" 92 | }); 93 | }; 94 | } 95 | 96 | visitObject(obj) { 97 | const fields = obj.getFields(); 98 | const expectedScopes = this.args.scopes; 99 | 100 | Object.keys(fields).forEach(fieldName => { 101 | const field = fields[fieldName]; 102 | const next = field.resolve; 103 | field.resolve = function(result, args, context, info) { 104 | const decoded = verifyAndDecodeToken({ context }); 105 | 106 | const scopes = process.env.AUTH_DIRECTIVES_SCOPE_KEY 107 | ? decoded[process.env.AUTH_DIRECTIVES_SCOPE_KEY] || [] 108 | : decoded["permissions"] || 109 | decoded["Permissions"] || 110 | decoded["Scopes"] || 111 | decoded["scopes"] || 112 | decoded["Scope"] || 113 | decoded["scope"] || 114 | []; 115 | 116 | if (expectedScopes.some(role => scopes.indexOf(role) !== -1)) { 117 | return next(result, args, { ...context, user: decoded }, info); 118 | } 119 | throw new AuthorizationError({ 120 | message: "You are not authorized for this resource" 121 | }); 122 | }; 123 | }); 124 | } 125 | } 126 | 127 | export class HasRoleDirective extends SchemaDirectiveVisitor { 128 | static getDirectiveDeclaration(directiveName, schema) { 129 | return new GraphQLDirective({ 130 | name: "hasRole", 131 | locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], 132 | args: { 133 | roles: { 134 | type: new GraphQLList(schema.getType("Role")), 135 | defaultValue: "reader" 136 | } 137 | } 138 | }); 139 | } 140 | 141 | visitFieldDefinition(field) { 142 | const expectedRoles = this.args.roles; 143 | const next = field.resolve; 144 | 145 | field.resolve = function(result, args, context, info) { 146 | const decoded = verifyAndDecodeToken({ context }); 147 | 148 | const roles = process.env.AUTH_DIRECTIVES_ROLE_KEY 149 | ? decoded[process.env.AUTH_DIRECTIVES_ROLE_KEY] || [] 150 | : decoded["Roles"] || 151 | decoded["roles"] || 152 | decoded["Role"] || 153 | decoded["role"] || 154 | []; 155 | 156 | if (expectedRoles.some(role => roles.indexOf(role) !== -1)) { 157 | return next(result, args, { ...context, user: decoded }, info); 158 | } 159 | 160 | throw new AuthorizationError({ 161 | message: "You are not authorized for this resource" 162 | }); 163 | }; 164 | } 165 | 166 | visitObject(obj) { 167 | const fields = obj.getFields(); 168 | const expectedRoles = this.args.roles; 169 | 170 | Object.keys(fields).forEach(fieldName => { 171 | const field = fields[fieldName]; 172 | const next = field.resolve; 173 | field.resolve = function(result, args, context, info) { 174 | const decoded = verifyAndDecodeToken({ context }); 175 | 176 | const roles = process.env.AUTH_DIRECTIVES_ROLE_KEY 177 | ? decoded[process.env.AUTH_DIRECTIVES_ROLE_KEY] || [] 178 | : decoded["Roles"] || 179 | decoded["roles"] || 180 | decoded["Role"] || 181 | decoded["role"] || 182 | []; 183 | 184 | if (expectedRoles.some(role => roles.indexOf(role) !== -1)) { 185 | return next(result, args, { ...context, user: decoded }, info); 186 | } 187 | throw new AuthorizationError({ 188 | message: "You are not authorized for this resource" 189 | }); 190 | }; 191 | }); 192 | } 193 | } 194 | 195 | export class IsAuthenticatedDirective extends SchemaDirectiveVisitor { 196 | static getDirectiveDeclaration(directiveName, schema) { 197 | return new GraphQLDirective({ 198 | name: "isAuthenticated", 199 | locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT] 200 | }); 201 | } 202 | 203 | visitObject(obj) { 204 | const fields = obj.getFields(); 205 | 206 | Object.keys(fields).forEach(fieldName => { 207 | const field = fields[fieldName]; 208 | const next = field.resolve; 209 | 210 | field.resolve = function(result, args, context, info) { 211 | const decoded = verifyAndDecodeToken({ context }); // will throw error if not valid signed jwt 212 | return next(result, args, { ...context, user: decoded }, info); 213 | }; 214 | }); 215 | } 216 | 217 | visitFieldDefinition(field) { 218 | const next = field.resolve; 219 | 220 | field.resolve = function(result, args, context, info) { 221 | const decoded = verifyAndDecodeToken({ context }); 222 | return next(result, args, { ...context, user: decoded }, info); 223 | }; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /test/helpers/test-setup.js: -------------------------------------------------------------------------------- 1 | // TODO: will need to set appropriate env vars 2 | 3 | const { ApolloServer } = require("apollo-server"); 4 | const { makeExecutableSchema } = require("graphql-tools"); 5 | 6 | const { 7 | IsAuthenticatedDirective, 8 | HasRoleDirective, 9 | HasScopeDirective 10 | } = require("../../src/index"); 11 | 12 | const dotenv = require("dotenv"); 13 | 14 | dotenv.config(); 15 | 16 | const typeDefs = ` 17 | 18 | directive @hasScope(scopes: [String]) on OBJECT | FIELD_DEFINITION 19 | directive @hasRole(roles: [Role]) on OBJECT | FIELD_DEFINITION 20 | directive @isAuthenticated on OBJECT | FIELD_DEFINITION 21 | 22 | enum Role { 23 | reader 24 | user 25 | admin 26 | } 27 | 28 | type User { 29 | id: ID! 30 | name: String 31 | } 32 | 33 | type Item { 34 | id: ID! 35 | name: String 36 | } 37 | 38 | type Query { 39 | userById(userId: ID!): User @hasScope(scopes: ["User:Read"]) 40 | itemById(itemId: ID!): Item @hasScope(scopes: ["Item:Read"]) 41 | } 42 | 43 | type Mutation { 44 | createUser(id: ID!, name: String): User @hasScope(scopes: ["User:Create"]) 45 | createItem(id: ID!, name: String): Item @hasScope(scopes: ["Item:Create"]) 46 | 47 | updateUser(id: ID!, name: String): User @hasScope(scopes: ["User:Update"]) 48 | updateItem(id: ID!, name: String): Item @hasScope(scopes: ["Item:Update"]) 49 | 50 | deleteUser(id: ID!): User @hasScope(scopes: ["User:Delete"]) 51 | deleteItem(id: ID!): Item @hasScope(scopes: ["Item:Delete"]) 52 | 53 | addUserItemRelationship(userId: ID!, itemId: ID!): User @hasScope(scopes: ["User:Create", "Item:Create"]) 54 | } 55 | `; 56 | 57 | const resolvers = { 58 | Query: { 59 | userById(object, params, ctx, resolveInfo) { 60 | console.log("userById resolver"); 61 | return { 62 | id: params.userId, 63 | name: "bob" 64 | }; 65 | }, 66 | itemById(object, params, ctx, resolveInfo) { 67 | console.log("itemById resolver"); 68 | return { 69 | id: "123", 70 | name: "bob" 71 | }; 72 | } 73 | }, 74 | Mutation: { 75 | createUser(object, params, ctx, resolveInfo) { 76 | // createUser mutation should never be called 77 | throw new Error("createUser resolver called"); 78 | }, 79 | createItem(object, params, ctx, resolveInfo) { 80 | console.log("createItem resolver"); 81 | }, 82 | updateUser(object, params, ctx, resolveInfo) { 83 | console.log("updateUser resolver"); 84 | }, 85 | updateItem(object, params, ctx, resolveInfo) { 86 | console.log("updateItem resolver"); 87 | }, 88 | deleteUser(object, params, ctx, resolveInfo) { 89 | console.log("deleteUser resolver"); 90 | }, 91 | deleteItem(object, params, ctx, resolveInfo) { 92 | console.log("deleteItem resolver"); 93 | }, 94 | addUserItemRelationship(object, params, ctx, resolveInfo) { 95 | console.log("addUserItemRelationship resolver"); 96 | } 97 | } 98 | }; 99 | 100 | const schema = makeExecutableSchema({ 101 | typeDefs, 102 | resolvers, 103 | schemaDirectives: { 104 | isAuthenticated: IsAuthenticatedDirective, 105 | hasRole: HasRoleDirective, 106 | hasScope: HasScopeDirective 107 | } 108 | }); 109 | 110 | const server = new ApolloServer({ 111 | schema, 112 | context: ({ req }) => { 113 | return req; 114 | } 115 | }); 116 | 117 | server.listen(3000).then(({ url }) => { 118 | console.log(`GraphQL server ready at ${url}`); 119 | }); 120 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | 3 | const { ApolloClient } = require("apollo-client"); 4 | const { createHttpLink } = require("apollo-link-http"); 5 | const { InMemoryCache } = require("apollo-cache-inmemory"); 6 | 7 | const gql = require("graphql-tag"); 8 | const fetch = require("node-fetch"); 9 | 10 | let client; 11 | 12 | const headers = { 13 | "x-error": "Middleware error" 14 | }; 15 | 16 | test.before(() => { 17 | client = new ApolloClient({ 18 | link: createHttpLink({ uri: "http://localhost:3000", fetch, headers }), 19 | cache: new InMemoryCache() 20 | }); 21 | }); 22 | 23 | test("Fail if no auth token", async t => { 24 | t.plan(1); 25 | 26 | const client = new ApolloClient({ 27 | link: createHttpLink({ uri: "http://localhost:3000", fetch }), 28 | cache: new InMemoryCache() 29 | }); 30 | 31 | await client 32 | .query({ 33 | query: gql` 34 | { 35 | userById(userId: "123456") { 36 | id 37 | name 38 | } 39 | } 40 | ` 41 | }) 42 | .then(data => { 43 | t.fail("AuthorizationError should be thrown"); 44 | }) 45 | .catch(error => { 46 | t.pass(); 47 | }); 48 | }); 49 | 50 | test("No error with token", async t => { 51 | t.plan(1); 52 | 53 | const headers = { 54 | Authorization: 55 | "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJHUkFORHN0YWNrIiwiaWF0IjoxNTQ5MTQ1Mjk0LCJleHAiOjE2OTE3ODEzMDcsImF1ZCI6ImdyYW5kc3RhY2suaW8iLCJzdWIiOiJib2JAbG9ibGF3LmNvbSIsIlJvbGUiOiJBRE1JTiIsIlNjb3BlIjpbIlVzZXI6UmVhZCIsIlVzZXI6Q3JlYXRlIiwiVXNlcjpVcGRhdGUiLCJVc2VyOkRlbGV0ZSJdfQ.WJffOec05r8KuzW76asax1iCzv5q4rwRv9kvFyw7c_E" 56 | }; 57 | 58 | const client = new ApolloClient({ 59 | link: createHttpLink({ uri: "http://localhost:3000", fetch, headers }), 60 | cache: new InMemoryCache() 61 | }); 62 | 63 | await client 64 | .query({ 65 | query: gql` 66 | { 67 | userById(userId: "123456") { 68 | id 69 | name 70 | } 71 | } 72 | ` 73 | }) 74 | .then(data => { 75 | // TODO: verify expected data 76 | t.pass(); 77 | }) 78 | .catch(error => { 79 | console.error(error); 80 | t.fail(); 81 | }); 82 | }); 83 | 84 | test("Mutation resolver is not called when Auth fails", async t => { 85 | t.plan(1); 86 | 87 | // This JWT does not contain User:Create scope claim 88 | const headers = { 89 | Authorization: 90 | "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJHUkFORHN0YWNrIiwiaWF0IjoxNTQ5MTQ1Mjk0LCJleHAiOjE2OTE3ODEzMDcsImF1ZCI6ImdyYW5kc3RhY2suaW8iLCJzdWIiOiJib2JAbG9ibGF3LmNvbSIsIlJvbGUiOiJBRE1JTiIsIlNjb3BlIjpbIlVzZXI6UmVhZCIsIlVzZXI6VXBkYXRlIiwiVXNlcjpEZWxldGUiXX0.J3VrFNSKToK1cZNrwdbKp-8YkO74_tkp82l3n39ZnK0" 91 | }; 92 | 93 | const client = new ApolloClient({ 94 | link: createHttpLink({ uri: "http://localhost:3000", fetch, headers }), 95 | cache: new InMemoryCache() 96 | }); 97 | 98 | await client 99 | .mutate({ 100 | mutation: gql` 101 | mutation { 102 | createUser(id: "1234", name: "Bob") { 103 | id 104 | } 105 | } 106 | ` 107 | }) 108 | .then(data => { 109 | t.fail("User should not be authorized for this mutation"); 110 | }) 111 | .catch(error => { 112 | //console.log(error.message); 113 | t.pass(); 114 | }); 115 | }); 116 | --------------------------------------------------------------------------------