├── .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 |
--------------------------------------------------------------------------------