Skip to content
This repository has been archived by the owner on Oct 21, 2020. It is now read-only.

Commit

Permalink
feat: add auth options 🎉 (#58)
Browse files Browse the repository at this point in the history
* feat(auth): add auth options 🎉

* feat(tools): add debug

* fix(directives): allow graphql-tools to handle directive resolvers

* fix(auth-errors): communicate verification errors to the client

* feat(jwt-cert): add cert to process.env

* chore(pem): remove references to .pem files

* chore(dependencies): add new packages to yarn.lock

* fix(jwt-cert): add jwt-cert to serverless.yml

* chore(tools): add jwt-cert to depoly script

* feat(tools): add initial directive tests scaffolding

* feat: support authentication in tests

* chore(tools): refactor integration tests

* feat(tools): add directive tests

* chore(tools): remove gulp in favour of cross-env

* chore(deps): remove package-lock.json

* remove dev-only code

* chore(tools): fix for ci
  • Loading branch information
Bouncey authored and raisedadead committed Apr 28, 2018
1 parent 2652e5c commit 2b9f1a0
Show file tree
Hide file tree
Showing 24 changed files with 458 additions and 780 deletions.
26 changes: 26 additions & 0 deletions auth/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import jwt from 'jsonwebtoken';
import debug from 'debug';

import { AuthorizationError } from '../graphql/errors';

const log = debug('fcc:auth');
const { JWT_CERT } = process.env;

export function verifyWebToken(ctx) {
log('Verifying token');
const token = ctx && ctx.headers && ctx.headers.authorization;
if (!token) {
throw new AuthorizationError({
message: 'You must supply a JSON Web Token for authorization!'
});
}
let decoded = null;
let error = null;
try {
decoded = jwt.verify(token.replace('Bearer ', ''), JWT_CERT);
} catch (err) {
error = err;
} finally {
return { decoded, error, isAuth: !!decoded };
}
}
7 changes: 4 additions & 3 deletions config/secrets.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
require('dotenv').config();

const { MONGODB_URL, GRAPHQL_ENDPOINT_URL } = process.env;
const { MONGODB_URL, GRAPHQL_ENDPOINT_URL, JWT_CERT } = process.env;

exports.getSecret = () => ({
MONGODB_URL,
GRAPHQL_ENDPOINT_URL
GRAPHQL_ENDPOINT_URL,
JWT_CERT,
MONGODB_URL
});
11 changes: 6 additions & 5 deletions dataLayer/mongo/user.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
const validator = require('validator');
const UserModel = require('../model/user.js');
const moment = require('moment');
const fs = require('fs');
import validator from 'validator';
import UserModel from '../model/user.js';
import debug from 'debug';

import { asyncErrorHandler } from '../../utils';

const log = debug('fcc:dataLayer:mongo:user');

function doesExist(Model, options) {
return Model.find(options).exec();
}

export function getUsers(args) {
export function getUsers(_, args = {}) {
return new Promise((resolve, reject) => {
UserModel.find(args)
.then(users => {
Expand Down
5 changes: 5 additions & 0 deletions graphql/errors/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createError } from 'apollo-errors';

export const AuthorizationError = createError('AuthorizationError', {
message: 'You are not authorized.'
});
62 changes: 62 additions & 0 deletions graphql/resolvers/directive.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* global expect beforeEach */
import sinon from 'sinon';
import sinonStubPromise from 'sinon-stub-promise';
import { createDirectives } from './directives';

sinonStubPromise(sinon);
const testErrorMsg = 'Test error message';
let nextSpy = sinon.spy();
let nextPromiseStub = sinon.stub().returnsPromise();
let authTrueStub = sinon.stub().returns({ isAuth: true });
let authFalseStub = sinon
.stub()
.returns({ isAuth: false, error: { message: testErrorMsg } });

beforeEach(() => {
authFalseStub.resetHistory();
authTrueStub.resetHistory();
nextSpy.resetHistory();
nextPromiseStub.resetHistory();
});

describe('isAuthenticatedOnField', () => {
it('should return null if authenication fails', () => {
const { isAuthenticatedOnField } = createDirectives(authFalseStub);
const secretValue = 'secret squirrel';
nextPromiseStub.resolves(secretValue);
const result = isAuthenticatedOnField(nextPromiseStub);

expect(authFalseStub.calledOnce).toBe(true);
expect(result.resolved).toBe(true);
expect(result.resolveValue).toBe(null);
});

it('should return the secretValue if authentication succeeds', () => {
const { isAuthenticatedOnField } = createDirectives(authTrueStub);
const secretValue = 'secret squirrel';
nextPromiseStub.resolves(secretValue);
const result = isAuthenticatedOnField(nextPromiseStub);

expect(authTrueStub.calledOnce).toBe(true);
expect(result.resolved).toBe(true);
expect(result.resolveValue).toBe(secretValue);
});
});

describe('isAuthenticatedOnQuery', () => {
it('should throw an error is auth fails', () => {
const { isAuthenticatedOnQuery } = createDirectives(authFalseStub);

expect(() => {
isAuthenticatedOnQuery(nextSpy);
}).toThrowError(testErrorMsg);
expect(nextSpy.called).toBe(false);
});

it('should call next if auth succeeds', () => {
const { isAuthenticatedOnQuery } = createDirectives(authTrueStub);
isAuthenticatedOnQuery(nextPromiseStub);

expect(nextPromiseStub.calledOnce).toBe(true);
});
});
30 changes: 30 additions & 0 deletions graphql/resolvers/directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { AuthorizationError } from '../errors';
import { verifyWebToken as _verifyWebToken } from '../../auth';
import { asyncErrorHandler } from '../../utils';

/*
Interface: {
Directive: (
next: Resolver <Promise>
source: any <From Data Source>, Example <User Object>
args: any, passed to directive
ctx: Lambda context <Object>
) => <Promise> | <Error>
}
*/

export const createDirectives = (verifyWebToken = _verifyWebToken) => ({
isAuthenticatedOnField: (next, source, args, ctx) => {
const { isAuth } = verifyWebToken(ctx);
return asyncErrorHandler(next().then(result => (isAuth ? result : null)));
},
isAuthenticatedOnQuery: (next, source, args, ctx) => {
const { isAuth, error } = verifyWebToken(ctx);
if (isAuth) {
return asyncErrorHandler(next());
}
throw new AuthorizationError({
message: `You are not authorized, ${error.message}`
});
}
});
1 change: 1 addition & 0 deletions graphql/resolvers/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mergeResolvers } from 'merge-graphql-schemas';
import { userResolvers } from './user';

export { createDirectives } from './directives';
export default mergeResolvers([userResolvers]);
4 changes: 2 additions & 2 deletions graphql/resolvers/user.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as dbUsers from '../../dataLayer/mongo/user';
import { createUser, getUsers } from '../../dataLayer/mongo/user';

export const userResolvers = {
Query: {
users: (_, args) => dbUsers.getUsers(args)
users: getUsers
},
Mutation: {
createUser: (_, { email }) => dbUsers.createUser(email)
Expand Down
2 changes: 1 addition & 1 deletion graphql/typeDefs/User/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ type Query {
_id: ID
name: String
email: String
): [User]
): [User] @isAuthenticatedOnQuery
}
`;
2 changes: 1 addition & 1 deletion graphql/typeDefs/User/type.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default `
type User {
_id: ID!
_id: ID @isAuthenticatedOnField
email: String
name: String
}
Expand Down
4 changes: 4 additions & 0 deletions graphql/typeDefs/directives/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default `
directive @isAuthenticatedOnField on FIELD | FIELD_DEFINITION
directive @isAuthenticatedOnQuery on FIELD | FIELD_DEFINITION
`;
3 changes: 2 additions & 1 deletion graphql/typeDefs/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mergeTypes } from 'merge-graphql-schemas';
import User from './User';
import directives from './directives';

export default mergeTypes([User]);
export default mergeTypes([User, directives]);
43 changes: 0 additions & 43 deletions gulpfile.js

This file was deleted.

65 changes: 42 additions & 23 deletions handler.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import { graphqlLambda, graphiqlLambda } from 'apollo-server-lambda';
import { graphqlLambda } from 'apollo-server-lambda';
import lambdaPlayground from 'graphql-playground-middleware-lambda';
import { makeExecutableSchema } from 'graphql-tools';
import debug from 'debug';

import typeDefs from './graphql/typeDefs';
import resolvers from './graphql/resolvers';
import { default as resolvers, createDirectives } from './graphql/resolvers';
import connectToDatabase from './dataLayer';

const log = debug('fcc:handler');

export const graphqlSchema = makeExecutableSchema({
typeDefs,
resolvers,
directiveResolvers: createDirectives(),
logger: console
});

// Database connection logic lives outside of the handler for performance reasons
const connectToDatabase = require('./dataLayer');

const server = require('apollo-server-lambda');

exports.graphqlHandler = function graphqlHandler(event, context, callback) {
exports.graphqlHandler = async function graphqlHandler(
event,
context,
callback
) {
/* Cause Lambda to freeze the process and save state data after
the callback is called the effect is that new handler invocations
will be able to re-use the database connection.
See https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html
and https://www.mongodb.com/blog/post/optimizing-aws-lambda-performance-with-mongodb-atlas-and-nodejs */
the callback is called. the effect is that new handler invocations
will be able to re-use the database connection.
See https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html
and https://www.mongodb.com/blog/post/optimizing-aws-lambda-performance-with-mongodb-atlas-and-nodejs */
context.callbackWaitsForEmptyEventLoop = false;

function callbackFilter(error, output) {
Expand All @@ -35,17 +40,31 @@ exports.graphqlHandler = function graphqlHandler(event, context, callback) {
callback(error, output);
}

const handler = server.graphqlLambda({ schema: graphqlSchema });

connectToDatabase()
.then(() => {
return handler(event, context, callbackFilter);
})
.catch(err => {
console.log('MongoDB connection error: ', err);
// TODO: return 500?
process.exit();
});
const handler = graphqlLambda((event, context) => {
const { headers } = event;
const { functionName } = context;

return {
schema: graphqlSchema,
context: {
headers,
functionName,
event,
context
}
};
});

try {
await connectToDatabase();
} catch (err) {
log('MongoDB connection error: ', err);
// TODO: return 500?
/* eslint-disable no-process-exit */
process.exit();
/* eslint-enable no-process-exit */
}
return handler(event, context, callbackFilter);
};

exports.apiHandler = lambdaPlayground({
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
globalSetup: './test/utils/setup.js',
globalTeardown: './test/utils/teardown.js',
testEnvironment: './test/utils/mongo-environment.js'
testEnvironment: './test/utils/test-environment.js'
};
Loading

0 comments on commit 2b9f1a0

Please sign in to comment.