Skip to content

Commit

Permalink
Merge pull request #169 from cofacts/auth-api
Browse files Browse the repository at this point in the history
@auth directive and context API
  • Loading branch information
MrOrz committed May 1, 2020
2 parents a28f1b6 + 97b491d commit 8a390ab
Show file tree
Hide file tree
Showing 10 changed files with 226 additions and 2 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -40,6 +40,7 @@
"googleapis": "^36.0.0",
"grapheme-splitter": "^1.0.4",
"graphql": "^15.0.0",
"graphql-tools": "^4.0.7",
"koa": "^2.5.0",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
Expand Down
60 changes: 60 additions & 0 deletions src/graphql/__tests__/context.js
@@ -0,0 +1,60 @@
import { gql } from '../testUtils';

it('context rejects anonymous users', async () => {
const result = await gql`
{
context {
state
}
}
`();
expect(result).toMatchInlineSnapshot(`
Object {
"data": Object {
"context": null,
},
"errors": Array [
[GraphQLError: Invalid authentication header],
],
}
`);
});

it('Returns user context', async () => {
const result = await gql`
{
context {
state
issuedAt
data {
searchedText
}
}
}
`(
{},
{
userId: 'U12345678',
userContext: {
state: 'CHOOSING_ARTICLE',
issuedAt: 1586013070089,
data: {
searchedText: 'Foo',
},
},
}
);
expect(result).toMatchInlineSnapshot(`
Object {
"data": Object {
"context": Object {
"data": Object {
"searchedText": "Foo",
},
"issuedAt": 1586013070089,
"state": "CHOOSING_ARTICLE",
},
},
}
`);
});
75 changes: 75 additions & 0 deletions src/graphql/__tests__/index.js
@@ -0,0 +1,75 @@
jest.mock('src/lib/redisClient');

import { getContext } from '../';
import redis from 'src/lib/redisClient';

beforeEach(() => {
redis.get.mockClear();
});

describe('getContext', () => {
it('generates null context for anonymous requests', async () => {
const context = await getContext({ ctx: { req: { headers: {} } } });

expect(context).toMatchInlineSnapshot(`
Object {
"userContext": null,
"userId": "",
}
`);
});

it('generates null context for wrong credentials', async () => {
redis.get.mockImplementationOnce(() => ({
nonce: 'correctpass',
}));

const context = await getContext({
ctx: {
req: {
headers: {
authorization: `basic ${Buffer.from('user1:wrongpass').toString(
'base64'
)}`,
},
},
},
});

expect(context).toMatchInlineSnapshot(`
Object {
"userContext": null,
"userId": "user1",
}
`);
});

it('reads userContext for logged-in requests', async () => {
redis.get.mockImplementationOnce(() => ({
nonce: 'correctpass',
foo: 'bar',
}));

const context = await getContext({
ctx: {
req: {
headers: {
authorization: `basic ${Buffer.from('user1:correctpass').toString(
'base64'
)}`,
},
},
},
});

expect(context).toMatchInlineSnapshot(`
Object {
"userContext": Object {
"foo": "bar",
"nonce": "correctpass",
},
"userId": "user1",
}
`);
});
});
1 change: 0 additions & 1 deletion src/graphql/__tests__/insights.js
@@ -1,4 +1,3 @@
jest.mock('@line/bot-sdk');
import {
mockedGetNumberOfMessageDeliveries,
mockedGetNumberOfFollowers,
Expand Down
23 changes: 23 additions & 0 deletions src/graphql/directives/auth.js
@@ -0,0 +1,23 @@
import { AuthenticationError } from 'apollo-server-koa';
import { SchemaDirectiveVisitor } from 'graphql-tools';

/**
* When field with @auth is accessed, make sure the user is logged in (i.e. `userContext` in context).
*/
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve } = field;

field.resolve = (...args) => {
const [, , context] = args;

if (!context.userId || !context.userContext) {
throw new AuthenticationError('Invalid authentication header');
}

return resolve(...args);
};
}
}

export default AuthDirective;
40 changes: 39 additions & 1 deletion src/graphql/index.js
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { ApolloServer, makeExecutableSchema } from 'apollo-server-koa';
import redis from 'src/lib/redisClient';

export const schema = makeExecutableSchema({
typeDefs: fs.readFileSync(path.join(__dirname, `./typeDefs.graphql`), {
Expand All @@ -14,8 +15,45 @@ export const schema = makeExecutableSchema({
] = require(`./resolvers/${fileName}`).default;
return resolvers;
}, {}),
schemaDirectives: fs
.readdirSync(path.join(__dirname, 'directives'))
.reduce((directives, fileName) => {
directives[
fileName.replace(/\.js$/, '')
] = require(`./directives/${fileName}`).default;
return directives;
}, {}),
});

const server = new ApolloServer({ schema });
/**
* @param {{ctx: Koa.Context}}
* @returns {object}
*/
export async function getContext({ ctx: { req } }) {
const [userId, nonce] = Buffer.from(
(req.headers.authorization || '').replace(/^basic /, ''),
'base64'
)
.toString()
.split(':');

let userContext = null;
if (userId && nonce) {
const context = await redis.get(userId);
if (context && context.nonce === nonce) {
userContext = context;
}
}

return {
userId,
userContext,
};
}

const server = new ApolloServer({
schema,
context: getContext,
});

export default server.getMiddleware();
4 changes: 4 additions & 0 deletions src/graphql/resolvers/Query.js
Expand Up @@ -3,4 +3,8 @@ export default {
// Resolvers in next level
return {};
},

context(root, args, context) {
return context.userContext;
},
};
3 changes: 3 additions & 0 deletions src/graphql/testUtils.js
@@ -1,3 +1,6 @@
// ./index.js contains imports to redisClient, which should be mocked in unit tests.
jest.mock('src/lib/redisClient');

import { graphql } from 'graphql';
import { schema } from './';

Expand Down
18 changes: 18 additions & 0 deletions src/graphql/typeDefs.graphql
@@ -1,5 +1,10 @@
directive @auth on FIELD_DEFINITION

type Query {
insights: MessagingAPIInsight

# Current user's chatbot context
context: UserContext @auth
}

type MessagingAPIInsight {
Expand Down Expand Up @@ -123,3 +128,16 @@ enum MessagingAPIInsightStatus {
UNREADY
OUT_OF_SERVICE
}

type UserContext {
state: String
issuedAt: Float
data: StateData
}

type StateData {
searchedText: String
selectedArticleId: ID
selectedArticleText: String
selectedReplyId: ID
}
3 changes: 3 additions & 0 deletions src/lib/__mocks__/redisClient.js
@@ -0,0 +1,3 @@
export default {
get: jest.fn(),
};

0 comments on commit 8a390ab

Please sign in to comment.