├── .gitignore ├── tsconfig.json ├── package.json ├── .github └── workflows │ └── main.yml ├── LICENSE ├── .all-contributorsrc ├── src └── index.ts ├── test └── index.test.ts └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build" 19 | }, 20 | "peerDependencies": {}, 21 | "husky": { 22 | "hooks": { 23 | "pre-commit": "tsdx lint" 24 | } 25 | }, 26 | "prettier": { 27 | "printWidth": 80, 28 | "semi": true, 29 | "singleQuote": true, 30 | "trailingComma": "es5" 31 | }, 32 | "name": "graphql-auth", 33 | "author": "Kurt Kemple", 34 | "module": "dist/app.esm.js", 35 | "devDependencies": { 36 | "husky": "^4.2.5", 37 | "tsdx": "^0.13.2", 38 | "tslib": "^1.12.0", 39 | "typescript": "^3.9.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kurt Kemple 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. -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "graphql-auth", 3 | "projectOwner": "kkemple", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": false, 9 | "contributors": [ 10 | { 11 | "login": "artgibson", 12 | "name": "artgibson", 13 | "avatar_url": "https://avatars1.githubusercontent.com/u/332115?v=4", 14 | "profile": "https://github.com/artgibson", 15 | "contributions": [ 16 | "code" 17 | ] 18 | }, 19 | { 20 | "login": "HaNdTriX", 21 | "name": "HaNdTriX", 22 | "avatar_url": "https://avatars3.githubusercontent.com/u/1265681?v=4", 23 | "profile": "http://henrikwenz.de/", 24 | "contributions": [ 25 | "code", 26 | "doc" 27 | ] 28 | }, 29 | { 30 | "login": "swissspidy", 31 | "name": "Pascal Birchler", 32 | "avatar_url": "https://avatars1.githubusercontent.com/u/841956?v=4", 33 | "profile": "https://pascalbirchler.com", 34 | "contributions": [ 35 | "code" 36 | ] 37 | }, 38 | { 39 | "login": "ScreamZ", 40 | "name": "Andréas Hanss", 41 | "avatar_url": "https://avatars2.githubusercontent.com/u/6640835?v=4", 42 | "profile": "https://www.linkedin.com/in/andreas-hanss/", 43 | "contributions": [ 44 | "code", 45 | "doc" 46 | ] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | type Resolver = (parent: any, params: any, context: any, info: any) => any; 2 | 3 | function validateScope(required: string[], provided: string[]): boolean { 4 | let hasScope = false; 5 | 6 | required.forEach((scope: string) => { 7 | provided.forEach(function(providedScope: string) { 8 | // user:* -> user:create, user:view:self 9 | var regex = new RegExp('^' + providedScope.replace('*', '.*') + '$'); 10 | if (regex.exec(scope)) { 11 | hasScope = true; 12 | } 13 | }); 14 | }); 15 | 16 | return hasScope; 17 | } 18 | 19 | export default function withAuth(scope: any, callback?: any) { 20 | const next: Resolver = callback ? callback : scope; 21 | 22 | return async function(_: any, __: any, context: any, info: any) { 23 | // will hold resolved scope, if any 24 | let finalScope; 25 | 26 | // if no auth object on context in resolver, error out 27 | if (!context.auth) { 28 | return new Error('`auth` property not found on context!'); 29 | } 30 | 31 | // if user is not authenticated, error out 32 | if (!context.auth.isAuthenticated) { 33 | return new Error('Not authenticated!'); 34 | } 35 | 36 | // determine if we need to check scopes 37 | const hasScope = !!callback; 38 | if (hasScope) { 39 | // check if scope is a function that resolves to required scopes 40 | if (typeof scope === 'function') { 41 | // we wrap the function in a promise so whether scope resolver is async or not we can handle it like it is 42 | finalScope = await Promise.resolve(() => scope(_, __, context, info)); 43 | } else { 44 | finalScope = scope; 45 | } 46 | 47 | if (finalScope && finalScope.length) { 48 | if ( 49 | !context.auth.scope || 50 | !validateScope(finalScope, context.auth.scope) 51 | ) { 52 | return new Error('Permission denied!'); 53 | } 54 | } 55 | } 56 | 57 | return next(_, __, context, info); 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import withAuth from '../src/index'; 3 | 4 | const noop = () => true; 5 | 6 | test('returns an error if auth context is missing', async () => { 7 | const result = await withAuth(noop)(null, null, {}, {}); 8 | expect(result).toBeInstanceOf(Error); 9 | }); 10 | 11 | test('returns an error if the user is not authenticated', async () => { 12 | const result = await withAuth(noop)( 13 | null, 14 | null, 15 | { 16 | auth: { 17 | isAuthenticated: false, 18 | }, 19 | }, 20 | {} 21 | ); 22 | expect(result).toBeInstanceOf(Error); 23 | }); 24 | 25 | test("returns an error if the user doesn't have proper scope", async () => { 26 | const result = await withAuth(['scope:user'], noop)( 27 | null, 28 | null, 29 | { 30 | auth: { 31 | isAuthenticated: true, 32 | scope: ['scope:post'], 33 | }, 34 | }, 35 | {} 36 | ); 37 | expect(result).toBeInstanceOf(Error); 38 | }); 39 | 40 | test('resolves if the user is authenticated', async () => { 41 | const result = await withAuth(noop)( 42 | null, 43 | null, 44 | { 45 | auth: { 46 | isAuthenticated: true, 47 | scope: [], 48 | }, 49 | }, 50 | {} 51 | ); 52 | expect(result).toBe(true); 53 | }); 54 | 55 | test('resolves if the user has correct scope', async () => { 56 | const result = await withAuth(['scope:user'], noop)( 57 | null, 58 | null, 59 | { 60 | auth: { 61 | isAuthenticated: true, 62 | scope: ['scope:user', 'scope:post'], 63 | }, 64 | }, 65 | {} 66 | ); 67 | expect(result).toBe(true); 68 | }); 69 | 70 | test('resolves if the user has a wildcard scope', async () => { 71 | const result = await withAuth(['scope:user'], noop)( 72 | null, 73 | null, 74 | { 75 | auth: { 76 | isAuthenticated: true, 77 | scope: ['scope:*'], 78 | }, 79 | }, 80 | {} 81 | ); 82 | expect(result).toBe(true); 83 | }); 84 | 85 | test('resolves if the user has a nested wildcard scope', async () => { 86 | const result = await withAuth(['scope:user:delete'], noop)( 87 | null, 88 | null, 89 | { 90 | auth: { 91 | isAuthenticated: true, 92 | scope: ['scope:user:*'], 93 | }, 94 | }, 95 | {} 96 | ); 97 | expect(result).toBe(true); 98 | }); 99 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GraphQL Auth 2 | 3 | 🔒 Authentication and authorization middleware for GraphQL. 4 | 5 | `graphql-auth` is a very simple middleware that easily integrates with any GraphQL server that follows the GraphQL API for resolvers. 6 | 7 | ## Getting Started 8 | 9 | ### How It Works 10 | 11 | `graphql-auth` exports a single function (middleware) `withAuth`. This function takes two parameters, the first is `scope` (if any) for authorization, and the second is the `callback` to call when auth checking is complete. Let's look at an example: 12 | 13 | ```javascript 14 | import withAuth from 'graphql-auth'; 15 | 16 | const resolvers = { 17 | Query: { 18 | users: withAuth(['users:view'], (root, args, context) => { ... }), 19 | ... 20 | } 21 | } 22 | ``` 23 | 24 | The way this works is `withAuth` looks for a special `auth` property on the `context` of the resolver. It expects the `auth` property to have two properties of its own: 25 | 26 | 1. `isAuthenticated` to tell if the user is logged in 27 | 2. `scope` scope of the logged in user (optional) 28 | 29 | This allows you to use any form of authentication already supported by common frameworks like `express` and `hapi`. Here is an example in Hapi.js: 30 | 31 | ```javascript 32 | import { graphqlHapi } from 'graphql-server-hapi'; 33 | import { makeExecutableSchema } from 'graphql-tools'; 34 | 35 | import typeDefs from './type-defs'; 36 | import resolvers from './resolvers'; 37 | 38 | const register = function(server, options, next) { 39 | const executableSchema = makeExecutableSchema({ 40 | resolvers, 41 | typeDefs, 42 | }); 43 | 44 | server.register( 45 | [ 46 | { 47 | register: graphqlHapi, 48 | options: { 49 | path: '/graphql', 50 | graphqlOptions: request => ({ 51 | pretty: true, 52 | schema: executableSchema, 53 | context: { 54 | auth: { 55 | isAuthenticated: request.auth.isAuthenticated, 56 | scope: request.auth.credentials 57 | ? request.auth.credentials.scope 58 | : null, 59 | }, 60 | }, 61 | }), 62 | }, 63 | }, 64 | ], 65 | error => { 66 | if (error) return next(error); 67 | next(); 68 | } 69 | ); 70 | }; 71 | 72 | register.attributes = { 73 | name: 'graphql-api', 74 | version: '1.0.0', 75 | }; 76 | 77 | export default register; 78 | ``` 79 | 80 | > For more in depth examples take a look at the [`graphql-auth-examples`](https://github.com/kkemple/graphql-auth-examples) repo. 81 | 82 | ### Installation 83 | 84 | ```shell 85 | yarn add graphql-auth 86 | ``` 87 | 88 | ### withAuth([scope,] callback) 89 | 90 | _Without scope_: 91 | 92 | ```javascript 93 | import withAuth from 'graphql-auth'; 94 | 95 | const resolvers = { 96 | Query: { 97 | users: withAuth((root, args, context, info) => { ... }), 98 | ... 99 | } 100 | } 101 | ``` 102 | 103 | _With scope_: 104 | 105 | ```javascript 106 | import withAuth from 'graphql-auth'; 107 | 108 | const resolvers = { 109 | Query: { 110 | users: withAuth(['users:view'], (root, args, context, info) => { ... }), 111 | ... 112 | } 113 | } 114 | ``` 115 | 116 | _With dynamic scope_: 117 | 118 | ```javascript 119 | import withAuth from 'graphql-auth'; 120 | 121 | const resolvers = { 122 | Query: { 123 | users: withAuth( 124 | (root, args, context, info) => { /* return scope based on resolver args */ }, 125 | (root, args, context, info) => { ... }), 126 | ... 127 | } 128 | } 129 | ``` 130 | 131 | ## Contributors 132 | 133 | Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 134 | 135 | 136 | 137 | | [
artgibson](https://github.com/artgibson)
[💻](https://github.com/kkemple/graphql-auth/commits?author=artgibson 'Code') | [
HaNdTriX](http://henrikwenz.de/)
[💻](https://github.com/kkemple/graphql-auth/commits?author=HaNdTriX 'Code') [📖](https://github.com/kkemple/graphql-auth/commits?author=HaNdTriX 'Documentation') | [
Pascal Birchler](https://pascalbirchler.com)
[💻](https://github.com/kkemple/graphql-auth/commits?author=swissspidy 'Code') | [
Andréas Hanss](https://www.linkedin.com/in/andreas-hanss/)
[💻](https://github.com/kkemple/graphql-auth/commits?author=ScreamZ 'Code') [📖](https://github.com/kkemple/graphql-auth/commits?author=ScreamZ 'Documentation') | 138 | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | 139 | 140 | 141 | 142 | 143 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! 144 | --------------------------------------------------------------------------------