From 2a833a5b06f7d1689728fd0ce85c895dc87ef68f Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 1 Sep 2020 16:27:40 -0700 Subject: [PATCH] feat(graphql): add support for auth checker and middleware Signed-off-by: Raymond Feng --- .../acceptance/graphql-context.acceptance.ts | 62 ++++++++++++ .../graphql-middleware.acceptance.ts | 96 ++++++++++++++++--- .../src/__tests__/acceptance/graphql-tests.ts | 8 +- .../acceptance/graphql.acceptance.ts | 27 +++++- .../src/graphql-resolvers/recipe-resolver.ts | 2 + extensions/graphql/README.md | 71 +++++++++++++- extensions/graphql/src/decorators/index.ts | 2 + extensions/graphql/src/graphql.container.ts | 11 ++- extensions/graphql/src/graphql.server.ts | 34 ++++++- extensions/graphql/src/keys.ts | 7 +- 10 files changed, 296 insertions(+), 24 deletions(-) create mode 100644 examples/graphql/src/__tests__/acceptance/graphql-context.acceptance.ts diff --git a/examples/graphql/src/__tests__/acceptance/graphql-context.acceptance.ts b/examples/graphql/src/__tests__/acceptance/graphql-context.acceptance.ts new file mode 100644 index 000000000000..f58e551740db --- /dev/null +++ b/examples/graphql/src/__tests__/acceptance/graphql-context.acceptance.ts @@ -0,0 +1,62 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-graphql +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {createBindingFromClass} from '@loopback/core'; +import {GraphQLBindings, GraphQLServer} from '@loopback/graphql'; +import {expect, supertest} from '@loopback/testlab'; +import {RecipesDataSource} from '../../datasources'; +import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver'; +import {RecipeRepository} from '../../repositories'; +import {sampleRecipes} from '../../sample-recipes'; +import {RecipeService} from '../../services/recipe.service'; +import {exampleQuery} from './graphql-tests'; + +describe('GraphQL context', () => { + let server: GraphQLServer; + let repo: RecipeRepository; + + before(givenServer); + after(stopServer); + + it('invokes middleware', async () => { + await supertest(server.httpServer?.url) + .post('/graphql') + .set('content-type', 'application/json') + .accept('application/json') + .send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery}) + .expect(200); + }); + + async function givenServer() { + server = new GraphQLServer({host: '127.0.0.1', port: 0}); + server.resolver(RecipeResolver); + + // Customize the GraphQL context with additional information for test verification + server.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(ctx => { + return {...ctx, meta: 'loopback'}; + }); + + // Register a GraphQL middleware to verify context resolution + server.middleware((resolverData, next) => { + expect(resolverData.context).to.containEql({meta: 'loopback'}); + return next(); + }); + + server.bind('recipes').to([...sampleRecipes]); + const repoBinding = createBindingFromClass(RecipeRepository); + server.add(repoBinding); + server.add(createBindingFromClass(RecipesDataSource)); + server.add(createBindingFromClass(RecipeService)); + await server.start(); + repo = await server.get(repoBinding.key); + await repo.start(); + } + + async function stopServer() { + if (!server) return; + await server.stop(); + repo.stop(); + } +}); diff --git a/examples/graphql/src/__tests__/acceptance/graphql-middleware.acceptance.ts b/examples/graphql/src/__tests__/acceptance/graphql-middleware.acceptance.ts index 1a0e21e80680..aea638abe233 100644 --- a/examples/graphql/src/__tests__/acceptance/graphql-middleware.acceptance.ts +++ b/examples/graphql/src/__tests__/acceptance/graphql-middleware.acceptance.ts @@ -3,26 +3,94 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {createRestAppClient, givenHttpServerConfig} from '@loopback/testlab'; -import {GraphqlDemoApplication} from '../../'; -import {runTests} from './graphql-tests'; +import {createBindingFromClass} from '@loopback/core'; +import {GraphQLBindings, GraphQLServer} from '@loopback/graphql'; +import {expect, supertest} from '@loopback/testlab'; +import {RecipesDataSource} from '../../datasources'; +import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver'; +import {RecipeRepository} from '../../repositories'; +import {sampleRecipes} from '../../sample-recipes'; +import {RecipeService} from '../../services/recipe.service'; +import {exampleQuery} from './graphql-tests'; describe('GraphQL middleware', () => { - let app: GraphqlDemoApplication; + let server: GraphQLServer; + let repo: RecipeRepository; - before(giveAppWithGraphQLMiddleware); - after(stopApp); + beforeEach(givenServer); + afterEach(stopServer); - runTests(() => createRestAppClient(app)); + it('invokes middleware', async () => { + const fieldNamesCapturedByMiddleware: string[] = []; + // Register a GraphQL middleware + server.middleware((resolverData, next) => { + // It's invoked for each field resolver + fieldNamesCapturedByMiddleware.push(resolverData.info.fieldName); + return next(); + }); - async function giveAppWithGraphQLMiddleware() { - app = new GraphqlDemoApplication({rest: givenHttpServerConfig()}); - await app.boot(); - await app.start(); - return app; + await startServerAndRepo(); + await supertest(server.httpServer?.url) + .post('/graphql') + .set('content-type', 'application/json') + .accept('application/json') + .send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery}) + .expect(200); + expect(fieldNamesCapturedByMiddleware).to.eql([ + // the query + 'recipe', + // field resolvers + 'title', + 'description', + 'ratings', + 'creationDate', + 'ratingsCount', + 'averageRating', + 'ingredients', + 'numberInCollection', + ]); + }); + + it('invokes authChecker', async () => { + const authChecks: string[] = []; + server + .bind(GraphQLBindings.GRAPHQL_AUTH_CHECKER) + .to((resolverData, roles) => { + authChecks.push(`${resolverData.info.fieldName} ${roles}`); + return true; + }); + await startServerAndRepo(); + await supertest(server.httpServer?.url) + .post('/graphql') + .set('content-type', 'application/json') + .accept('application/json') + .send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery}) + .expect(200); + expect(authChecks).to.eql([ + // the query + 'recipe owner', + ]); + }); + + async function givenServer() { + server = new GraphQLServer({host: '127.0.0.1', port: 0}); + server.resolver(RecipeResolver); + server.bind('recipes').to([...sampleRecipes]); + const repoBinding = createBindingFromClass(RecipeRepository); + server.add(repoBinding); + server.add(createBindingFromClass(RecipesDataSource)); + server.add(createBindingFromClass(RecipeService)); + repo = await server.get(repoBinding.key); + } + + async function startServerAndRepo() { + await server.start(); + await repo.start(); } - async function stopApp() { - await app?.stop(); + async function stopServer() { + if (!server) return; + await server.stop(); + repo.stop(); } }); diff --git a/examples/graphql/src/__tests__/acceptance/graphql-tests.ts b/examples/graphql/src/__tests__/acceptance/graphql-tests.ts index b76446ff4daa..e82b3919a9e9 100644 --- a/examples/graphql/src/__tests__/acceptance/graphql-tests.ts +++ b/examples/graphql/src/__tests__/acceptance/graphql-tests.ts @@ -25,7 +25,7 @@ export function runTests(getClient: () => supertest.SuperTest) { .post('/graphql') .set('content-type', 'application/json') .accept('application/json') - .send({operationName: 'GetRecipe1', variables: {}, query: example}) + .send({operationName: 'GetRecipe1', variables: {}, query: exampleQuery}) .expect(200, expectedData); }); @@ -34,7 +34,7 @@ export function runTests(getClient: () => supertest.SuperTest) { .post('/graphql') .set('content-type', 'application/json') .accept('application/json') - .send({operationName: 'AddRecipe', variables: {}, query: example}) + .send({operationName: 'AddRecipe', variables: {}, query: exampleQuery}) .expect(200); expect(res.body.data.addRecipe).to.containEql({ id: '4', @@ -81,13 +81,13 @@ export function runTests(getClient: () => supertest.SuperTest) { .post('/graphql') .set('content-type', 'application/json') .accept('application/json') - .send({operationName: 'GetRecipes', variables: {}, query: example}) + .send({operationName: 'GetRecipes', variables: {}, query: exampleQuery}) .expect(200); expect(res.body.data.recipes).to.eql(expectedRecipes); }); } -const example = `query GetRecipe1 { +export const exampleQuery = `query GetRecipe1 { recipe(recipeId: "1") { title description diff --git a/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts b/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts index d7c064da3ce4..b48247e63f63 100644 --- a/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts +++ b/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts @@ -5,7 +5,12 @@ import {Application, createBindingFromClass} from '@loopback/core'; import {GraphQLServer} from '@loopback/graphql'; -import {supertest} from '@loopback/testlab'; +import { + createRestAppClient, + givenHttpServerConfig, + supertest, +} from '@loopback/testlab'; +import {GraphqlDemoApplication} from '../../'; import {RecipesDataSource} from '../../datasources'; import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver'; import {RecipeRepository} from '../../repositories'; @@ -72,3 +77,23 @@ describe('GraphQL application', () => { await app.stop(); } }); + +describe('GraphQL as middleware', () => { + let app: GraphqlDemoApplication; + + before(giveAppWithGraphQLMiddleware); + after(stopApp); + + runTests(() => createRestAppClient(app)); + + async function giveAppWithGraphQLMiddleware() { + app = new GraphqlDemoApplication({rest: givenHttpServerConfig()}); + await app.boot(); + await app.start(); + return app; + } + + async function stopApp() { + await app?.stop(); + } +}); diff --git a/examples/graphql/src/graphql-resolvers/recipe-resolver.ts b/examples/graphql/src/graphql-resolvers/recipe-resolver.ts index 9909292ca653..b3d9bb9a305f 100644 --- a/examples/graphql/src/graphql-resolvers/recipe-resolver.ts +++ b/examples/graphql/src/graphql-resolvers/recipe-resolver.ts @@ -6,6 +6,7 @@ import {inject, service} from '@loopback/core'; import { arg, + authorized, fieldResolver, GraphQLBindings, Int, @@ -34,6 +35,7 @@ export class RecipeResolver implements ResolverInterface { ) {} @query(returns => Recipe, {nullable: true}) + @authorized('owner') async recipe(@arg('recipeId') recipeId: string) { return this.recipeRepo.getOne(recipeId); } diff --git a/extensions/graphql/README.md b/extensions/graphql/README.md index c4ca0562e3ed..0e8b8afa6e23 100644 --- a/extensions/graphql/README.md +++ b/extensions/graphql/README.md @@ -273,6 +273,10 @@ The `GraphQLServer` allows you to propagate context from Express to resolvers. ### Register a GraphQL context resolver +The GraphQL context object can be built/enhanced by the context resolver. The +original value is `{req: Request, res: Response}` that represents the Express +request and response object. + ```ts export class GraphqlDemoApplication extends BootMixin( RepositoryMixin(RestApplication), @@ -303,11 +307,76 @@ export class RecipeResolver implements ResolverInterface { @service(RecipeService) private readonly recipeService: RecipeService, // It's possible to inject the resolver data @inject(GraphQLBindings.RESOLVER_DATA) private resolverData: ResolverData, - ) {} + ) { + // `this.resolverData.context` is the GraphQL context + } // ... } ``` +### Set up authorization checker + +We can customize the `authChecker` for +[TypeGraphQL Authorization](https://typegraphql.com/docs/authorization.html). + +```ts +export class GraphqlDemoApplication extends BootMixin( + RepositoryMixin(RestApplication), +) { + constructor(options: ApplicationConfig = {}) { + super(options); + + // ... + // It's possible to register a graphql auth checker + this.bind(GraphQLBindings.GRAPHQL_AUTH_CHECKER).to( + (resolverData, roles) => { + // Use resolverData and roles for authorization + return true; + }, + ); + } + // ... +} +``` + +The resolver classes and graphql types can be decorated with `@authorized` to +enforce authorization. + +```ts +@resolver(of => Recipe) +export class RecipeResolver implements ResolverInterface { + constructor() {} // ... + + @query(returns => Recipe, {nullable: true}) + @authorized('owner') // Authorized against `owner` role + async recipe(@arg('recipeId') recipeId: string) { + return this.recipeRepo.getOne(recipeId); + } +} +``` + +## Register GraphQL middleware + +We can register one or more +[TypeGraphQL Middleware](https://typegraphql.com/docs/middlewares.html) as +follows: + +```ts +export class GraphqlDemoApplication extends BootMixin( + RepositoryMixin(RestApplication), +) { + constructor(options: ApplicationConfig = {}) { + super(options); + + // Register a GraphQL middleware + this.middleware((resolverData, next) => { + // It's invoked for each field resolver, query and mutation operations + return next(); + }); + } +} +``` + ## Try it out Check out diff --git a/extensions/graphql/src/decorators/index.ts b/extensions/graphql/src/decorators/index.ts index d29e3a324af3..ca522c7126f2 100644 --- a/extensions/graphql/src/decorators/index.ts +++ b/extensions/graphql/src/decorators/index.ts @@ -9,6 +9,7 @@ import { Arg, Args, ArgsType, + Authorized, Field, FieldResolver, InputType, @@ -36,3 +37,4 @@ export const root = Root; export const field = Field; export const inputType = InputType; export const objectType = ObjectType; +export const authorized = Authorized; diff --git a/extensions/graphql/src/graphql.container.ts b/extensions/graphql/src/graphql.container.ts index 4ad4a43f2da8..168f6b75c624 100644 --- a/extensions/graphql/src/graphql.container.ts +++ b/extensions/graphql/src/graphql.container.ts @@ -11,11 +11,13 @@ import { filterByKey, filterByServiceInterface, } from '@loopback/core'; +import {ExpressContext} from 'apollo-server-express/dist/ApolloServer'; import debugFactory from 'debug'; import {ContainerType, ResolverData} from 'type-graphql'; import {GraphQLBindings, GraphQLTags} from './keys'; const debug = debugFactory('loopback:graphql:container'); +const MIDDLEWARE_CONTEXT = Symbol.for('loopback.middleware.context'); /** * Context for graphql resolver resolution @@ -43,8 +45,15 @@ export class LoopBackContainer implements ContainerType { resolverData: ResolverData, ) { debug('Resolving a resolver %s', resolverClass.name, resolverData); + + // Check if the resolverData has the LoopBack RequestContext + const graphQLCtx = resolverData.context as ExpressContext; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reqCtx = (graphQLCtx?.req as any)?.[MIDDLEWARE_CONTEXT]; + const parent = reqCtx ?? this.ctx; + const resolutionCtx = new GraphQLResolutionContext( - this.ctx, + parent, resolverClass, resolverData, ); diff --git a/extensions/graphql/src/graphql.server.ts b/extensions/graphql/src/graphql.server.ts index dccbcb0ea009..3df41e84a1b2 100644 --- a/extensions/graphql/src/graphql.server.ts +++ b/extensions/graphql/src/graphql.server.ts @@ -5,6 +5,7 @@ import { Binding, + BindingKey, BindingScope, config, Constructor, @@ -21,7 +22,13 @@ import {ContextFunction} from 'apollo-server-core'; import {ApolloServer, ApolloServerExpressConfig} from 'apollo-server-express'; import {ExpressContext} from 'apollo-server-express/dist/ApolloServer'; import express from 'express'; -import {buildSchema, NonEmptyArray, ResolverInterface} from 'type-graphql'; +import { + AuthChecker, + buildSchema, + NonEmptyArray, + ResolverInterface, +} from 'type-graphql'; +import {Middleware} from 'type-graphql/dist/interfaces/Middleware'; import {LoopBackContainer} from './graphql.container'; import {GraphQLBindings, GraphQLTags} from './keys'; @@ -83,14 +90,35 @@ export class GraphQLServer extends Context implements Server { .map(b => b.valueConstructor as Constructor>); } + async getMiddlewares(): Promise[]> { + const view = this.createView>( + filterByTag(GraphQLTags.MIDDLEWARE), + ); + return view.values(); + } + + middleware(middleware: Middleware): Binding> { + return this.bind>( + BindingKey.generate(`graphql.middleware`), + ) + .to(middleware) + .tag(GraphQLTags.MIDDLEWARE); + } + resolver(resolverClass: Constructor>) { - registerResolver(this, resolverClass); + return registerResolver(this, resolverClass); } async start() { const resolverClasses = (this.getResolvers() as unknown) as NonEmptyArray< Function >; + + const authChecker: AuthChecker = + (await this.get(GraphQLBindings.GRAPHQL_AUTH_CHECKER, { + optional: true, + })) ?? ((resolverData, roles) => true); + // build TypeGraphQL executable schema const schema = await buildSchema({ // See https://github.com/MichalLytek/type-graphql/issues/150#issuecomment-420181526 @@ -99,6 +127,8 @@ export class GraphQLServer extends Context implements Server { // automatically create `schema.gql` file with schema definition in current folder // emitSchemaFile: path.resolve(__dirname, 'schema.gql'), container: new LoopBackContainer(this), + authChecker, + globalMiddlewares: await this.getMiddlewares(), }); // Allow a graphql context resolver to be bound to GRAPHQL_CONTEXT_RESOLVER diff --git a/extensions/graphql/src/keys.ts b/extensions/graphql/src/keys.ts index 914124ed93f5..70f829b99a05 100644 --- a/extensions/graphql/src/keys.ts +++ b/extensions/graphql/src/keys.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {BindingKey, Constructor} from '@loopback/core'; -import {ResolverData} from 'type-graphql'; +import {AuthChecker, ResolverData} from 'type-graphql'; import {GraphQLComponent} from './graphql.component'; import {ContextFunction, ExpressContext, GraphQLServer} from './graphql.server'; @@ -21,6 +21,10 @@ export namespace GraphQLBindings { ContextFunction >('graphql.contextResolver'); + export const GRAPHQL_AUTH_CHECKER = BindingKey.create( + 'graphql.authChecker', + ); + export const RESOLVER_DATA = BindingKey.create>( 'graphql.resolverData', ); @@ -35,4 +39,5 @@ export namespace GraphQLBindings { export namespace GraphQLTags { export const GRAPHQL = 'graphql'; export const RESOLVER = 'graphql.resolver'; + export const MIDDLEWARE = 'graphql.middleware'; }