├── .babelrc ├── .gitignore ├── LICENSE.md ├── README.md ├── core ├── actions.js ├── auth.js └── store.js ├── graphql └── schema.js ├── index.js ├── package.json └── server ├── routes.js └── server.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 1 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.iml -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michal Ostruszka 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 | ## Authentication and authorization in GraphQL-based apps 2 | 3 | One approach to authenticate and authorize actions in GraphQL-based servers built using GraphQL reference implementation. 4 | 5 | ___GraphQL itself is authentication/authorization-agnostic, you can use anything you want to do this auth-part of your app. This repo just demonstrates how to work with already authenticated users on GraphQL-level operations___ 6 | 7 | As for the use case this is simple todo list, nothing fancy. 8 | 9 | Thanks to [@charypar](http://twitter.com/charypar) for help and valuable discussion while I was sorting that stuff out. 10 | 11 | ### Requirements 12 | 13 | #### All queries/mutations should be available only to authenticated users 14 | 15 | This is done in two steps: 16 | 17 | 1. Express middlewares that reject request if no `Auth` header is present (or its value is empty string) or authenticate user with provided token. 18 | 2. GraphQL is called with 3rd parameter - context, that contains authenticated user. It is then passed down to actions to authorize user provided. 19 | 20 | #### Each action requires user having certain permissions otherwise it will not be executed 21 | 22 | This is implemented in actions themselves as this is part of core business-logic. Each action has guard on entry that checks whether user provided is allowed to execute it. Authenticated user is provided as first argument to action, so action already has everything it needs to make this check. 23 | 24 | 25 | ### Implementation 26 | 27 | `graphql` entry point usually takes two params: schema definition and query to execute. There is third parameter, I call it context that seems to be user-defined thing that is being passed down to all GraphQL operations (including `resolve` functions). 28 | This `context` param is populated with authenticated user instance, passed to `graphql` function and then passed down to action functions. It allows actions themselves to decide whether given user is allowed to execute them or not. If user is not allowed (has no roles required), error is thrown. 29 | 30 | In the real world it could be promise-based flow, but here it just throws Error to keep things simple. This error is intercepted by GraphQL internals and returned to called as regular GraphQL error (as stated in spec). 31 | 32 | 33 | The only thing I'm worried about is that reference implementation returns promise that is always resolved, no matter if query was executed successfully or error was thrown. Also potential errors are returned as string messages only. These two things make it quite hard to distinct different types of errors and react accordingly (e.g. by returning correct HTTP status). 34 | 35 | 36 | ### Try it at home 37 | 38 | 1. Install stuff with `npm install` 39 | 2. Run server with `npm run dev`. It will start server and you should be interested in [http://localhost:3000/api](http://localhost:3000/api) endpoint. 40 | 3. Issue any of the following requests to GraphQL endpoint. They need to be `POST` requests with `Content-Type` header set to `application/graphql`. 41 | 4. All requests must have `Auth` header with token value of the user you are executing this request as. For the list of users and their tokens see [core/auth.js](core/auth.js) file 42 | 43 | #### Get todo items on list 44 | 45 | The following query will return list of all not yet completed items. It requires authenticated user (as mentioned above) with `read` role, otherwise it will return an error. Try it with different users and see how it works. 46 | 47 | ``` 48 | { 49 | items { 50 | id, 51 | title, 52 | completed 53 | } 54 | } 55 | ``` 56 | 57 | You may add query param as `items(includeCompleted: true)` to include completed items too. 58 | 59 | #### Mark item as completed 60 | 61 | This one marks item with given ID (see [core/store.js](core/store.js) file) as completed. It requires authenticated user to with `write` role, otherwise it will return an error as well. 62 | 63 | ``` 64 | mutation completeItem { 65 | markItemAsCompleted(id: 2) { 66 | id, 67 | title, 68 | completed 69 | } 70 | } 71 | ``` 72 | 73 | #### Add new item 74 | 75 | This one adds new item and also requires authenticated userwith `write` role. 76 | 77 | ``` 78 | mutation addItemToList { 79 | addItem(title: "take some sleep") { 80 | id, 81 | title, 82 | completed 83 | } 84 | } 85 | ``` 86 | -------------------------------------------------------------------------------- /core/actions.js: -------------------------------------------------------------------------------- 1 | import {Option} from 'giftbox'; 2 | 3 | import items from './store'; 4 | import * as auth from './auth'; 5 | 6 | const actions = { 7 | 8 | listItems(user, data) { 9 | auth.haltOnMissingRole(user, 'read'); 10 | const {includeCompleted = false} = data; 11 | return includeCompleted ? items : items.filter(i => !i.completed) 12 | }, 13 | 14 | markItemAsCompleted(user, itemId) { 15 | auth.haltOnMissingRole(user, 'write'); 16 | return Option(items.find(i => i.id === itemId)) 17 | .map(i => { 18 | i.completed = true; 19 | return i; 20 | }).getOrElse(() => { throw new Error(`Could not find todo item ${itemId}`) } ) 21 | }, 22 | 23 | addItem(user, itemData) { 24 | auth.haltOnMissingRole(user, 'write'); 25 | const maxId = Math.max(...items.map(i => i.id)); 26 | const newItem = {id: maxId + 1, completed: false, ...itemData}; 27 | items.push(newItem); 28 | return newItem; 29 | } 30 | 31 | }; 32 | 33 | export default actions; -------------------------------------------------------------------------------- /core/auth.js: -------------------------------------------------------------------------------- 1 | import {intersection} from 'lodash'; 2 | 3 | // token -> user 4 | const users = { 5 | '123abc': { name: 'Alice', roles: ['read', 'write'] }, 6 | '456def': { name: 'Bob', roles: ['read'] }, 7 | '789ghi': { name: 'Noop', roles: [] } 8 | }; 9 | 10 | export function authenticateUser(token) { 11 | return users[token]; 12 | } 13 | 14 | export function haltOnMissingRole(user, ...roles) { 15 | const allowed = intersection(roles, user.roles).length === roles.length; 16 | if(!allowed) throw new Error('User is not allowed to execute this action'); 17 | } -------------------------------------------------------------------------------- /core/store.js: -------------------------------------------------------------------------------- 1 | const items = [ 2 | {id: 1, title: 'learn graphql basics', completed: true}, 3 | {id: 2, title: 'tinker with graphql authentication', completed: false}, 4 | {id: 3, title: 'master more details of graphql', completed: false} 5 | ]; 6 | 7 | export default items; 8 | 9 | -------------------------------------------------------------------------------- /graphql/schema.js: -------------------------------------------------------------------------------- 1 | import { graphql, GraphQLString, GraphQLInt, GraphQLBoolean } from 'graphql'; 2 | import { objectType, schemaFrom, listOf, notNull } from 'graphql-schema'; 3 | 4 | import actions from '../core/actions'; 5 | 6 | const todoItemType = objectType('TodoItemType') 7 | .field('id', notNull(GraphQLInt), 'Task id') 8 | .field('title', notNull(GraphQLString), 'Task title') 9 | .field('completed', notNull(GraphQLBoolean), 'Task state') 10 | .end(); 11 | 12 | const queryType = objectType('QueryRoot') 13 | .field('items', listOf(todoItemType)) 14 | .arg('includeCompleted', GraphQLBoolean) 15 | .resolve((root, data, context) => actions.listItems(context.user, data)) 16 | .end(); 17 | 18 | const mutationType = objectType('MutationRoot') 19 | .field('markItemAsCompleted', todoItemType) 20 | .arg('itemId', notNull(GraphQLInt)) 21 | .resolve((root, {itemId}, context) => actions.markItemAsCompleted(context.user, itemId)) 22 | .field('addItem', todoItemType) 23 | .arg('title', notNull(GraphQLString)) 24 | .resolve((root, itemData, context) => actions.addItem(context.user, itemData)) 25 | .end(); 26 | 27 | export default schemaFrom(queryType, mutationType); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('babel/register'); 2 | require('./server/server.js'); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-todos", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "dev": "`npm bin`/nodemon index.js" 7 | }, 8 | "author": "Michał Ostruszka", 9 | "license": "MIT", 10 | "dependencies": { 11 | "babel": "^5.6.14", 12 | "body-parser": "^1.13.2", 13 | "express": "^4.13.1", 14 | "giftbox": "^0.5.1", 15 | "graphql": "^0.1.3", 16 | "graphql-schema": "^0.5.1", 17 | "lodash": "^3.10.0" 18 | }, 19 | "devDependencies": { 20 | "nodemon": "^1.3.7" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | import {graphql} from 'graphql'; 2 | 3 | import todoSchema from '../graphql/schema'; 4 | import {authenticateUser} from '../core/auth'; 5 | 6 | function handleRequest(req, res) { 7 | const context = { user: req.user }; 8 | const query = req.body; 9 | graphql(todoSchema, query, context).then(handleResponse(res)); 10 | } 11 | 12 | function handleResponse(res) { 13 | return (result) => { 14 | if(result.errors && result.errors.length) { 15 | const msgs = result.errors.map(e => e.message); 16 | return sendErrors(res, ...msgs) 17 | } 18 | return res.status(200).json(result); 19 | } 20 | } 21 | 22 | export default (app) => { 23 | app.post('/api', [ensureAuthTokenPresent, ensureUserAuthenticated], handleRequest 24 | ) 25 | }; 26 | 27 | 28 | // auth middlewares and helpers 29 | 30 | function ensureAuthTokenPresent(req, res, next) { 31 | const authToken = req.get('Auth'); 32 | if(authToken && authToken.length) { 33 | req.authToken = authToken; 34 | return next(); 35 | } 36 | return sendErrors(res, 'Auth token not found in request.'); 37 | } 38 | 39 | function ensureUserAuthenticated(req, res, next) { 40 | const user = authenticateUser(req.authToken); 41 | if(user) { 42 | req.user = user; 43 | return next(); 44 | } 45 | return sendErrors(res, 'Could not authenticate user'); 46 | } 47 | 48 | function sendErrors(res, ...msgs) { 49 | return res.status(400).json({ errors: msgs }); 50 | } 51 | 52 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import defineRoutes from './routes'; 3 | import bodyParser from 'body-parser'; 4 | 5 | const app = express(); 6 | 7 | app.use(bodyParser.text({type: 'application/graphql'})); 8 | 9 | defineRoutes(app); 10 | 11 | const port = 3000; 12 | const server = app.listen(port, () => { 13 | console.log(`Started at http://localhost:${port}`); 14 | }); --------------------------------------------------------------------------------