From 6b547ddc78f18606401f4caa9792f7cecab29ddd Mon Sep 17 00:00:00 2001 From: Alle <111279668+a-alle@users.noreply.github.com> Date: Thu, 7 Mar 2024 14:32:21 +0000 Subject: [PATCH] Support authentication on root custom resolver fields (#4816) --- .changeset/chilly-panthers-love.md | 5 + packages/graphql/src/classes/Neo4jGraphQL.ts | 31 +- .../utils/generate-resolvers-composition.ts | 81 +++ .../graphql/src/schema-model/Operation.ts | 19 + .../src/schema-model/OperationAdapter.ts | 8 + .../src/schema-model/generate-model.ts | 16 +- .../src/schema/make-augmented-schema.ts | 10 + .../valid-directive-field-location.ts | 16 +- .../validation/validate-document.test.ts | 50 +- .../authorization/check-authentication.ts | 38 +- .../utils/apply-authentication.ts | 13 +- .../translate/translate-top-level-cypher.ts | 6 +- .../tests/integration/issues/3746.int.test.ts | 465 ++++++++++++++++++ 13 files changed, 689 insertions(+), 69 deletions(-) create mode 100644 .changeset/chilly-panthers-love.md create mode 100644 packages/graphql/src/classes/utils/generate-resolvers-composition.ts create mode 100644 packages/graphql/tests/integration/issues/3746.int.test.ts diff --git a/.changeset/chilly-panthers-love.md b/.changeset/chilly-panthers-love.md new file mode 100644 index 0000000000..37c44653eb --- /dev/null +++ b/.changeset/chilly-panthers-love.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": minor +--- + +Adds support for the `@authentication` directive on custom resolved fields of root types Query and Mutation diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 9f9ad5913d..c2fc4e3fe3 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -33,7 +33,7 @@ import { getDefinitionNodes } from "../schema/get-definition-nodes"; import { makeDocumentToAugment } from "../schema/make-document-to-augment"; import type { WrapResolverArguments } from "../schema/resolvers/composition/wrap-query-and-mutation"; import { wrapQueryAndMutation } from "../schema/resolvers/composition/wrap-query-and-mutation"; -import { wrapSubscription } from "../schema/resolvers/composition/wrap-subscription"; +import { wrapSubscription, type WrapSubscriptionArgs } from "../schema/resolvers/composition/wrap-subscription"; import { defaultFieldResolver } from "../schema/resolvers/field/defaultField"; import { validateDocument } from "../schema/validation"; import { validateUserDefinition } from "../schema/validation/schema-validation"; @@ -50,6 +50,7 @@ import { Neo4jGraphQLSubscriptionsDefaultEngine } from "./subscription/Neo4jGrap import type { AssertIndexesAndConstraintsOptions } from "./utils/asserts-indexes-and-constraints"; import { assertIndexesAndConstraints } from "./utils/asserts-indexes-and-constraints"; import checkNeo4jCompat from "./utils/verify-database"; +import { generateResolverComposition } from "./utils/generate-resolvers-composition"; type TypeDefinitions = string | DocumentNode | TypeDefinitions[] | (() => TypeDefinitions); @@ -283,22 +284,28 @@ class Neo4jGraphQL { authorization: this.authorization, jwtPayloadFieldsMap: this.jwtFieldsMap, }; + const queryAndMutationWrappers = [wrapQueryAndMutation(wrapResolverArgs)]; - const resolversComposition = { - "Query.*": [wrapQueryAndMutation(wrapResolverArgs)], - "Mutation.*": [wrapQueryAndMutation(wrapResolverArgs)], + const isSubscriptionEnabled = !!this.features.subscriptions; + const wrapSubscriptionResolverArgs = { + subscriptionsEngine: this.features.subscriptions, + schemaModel: this.schemaModel, + authorization: this.authorization, + jwtPayloadFieldsMap: this.jwtFieldsMap, }; + const subscriptionWrappers = isSubscriptionEnabled + ? [wrapSubscription(wrapSubscriptionResolverArgs as WrapSubscriptionArgs)] + : []; - if (this.features.subscriptions) { - resolversComposition["Subscription.*"] = wrapSubscription({ - subscriptionsEngine: this.features.subscriptions, - schemaModel: this.schemaModel, - authorization: this.authorization, - jwtPayloadFieldsMap: this.jwtFieldsMap, - }); - } + const resolversComposition = generateResolverComposition({ + schemaModel: this.schemaModel, + isSubscriptionEnabled, + queryAndMutationWrappers, + subscriptionWrappers, + }); // Merge generated and custom resolvers + // Merging must be done before composing because wrapper won't run otherwise const mergedResolvers = mergeResolvers([...asArray(resolvers), ...asArray(this.resolvers)]); return composeResolvers(mergedResolvers, resolversComposition); } diff --git a/packages/graphql/src/classes/utils/generate-resolvers-composition.ts b/packages/graphql/src/classes/utils/generate-resolvers-composition.ts new file mode 100644 index 0000000000..8093b6d5dc --- /dev/null +++ b/packages/graphql/src/classes/utils/generate-resolvers-composition.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ResolversComposerMapping } from "@graphql-tools/resolvers-composition"; +import type { IResolvers } from "@graphql-tools/utils"; +import type { GraphQLResolveInfo } from "graphql"; +import type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; +import { isAuthenticated } from "../../translate/authorization/check-authentication"; + +export function generateResolverComposition({ + schemaModel, + isSubscriptionEnabled, + queryAndMutationWrappers, + subscriptionWrappers, +}: { + schemaModel: Neo4jGraphQLSchemaModel; + isSubscriptionEnabled: boolean; + queryAndMutationWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[]; + subscriptionWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[]; +}): ResolversComposerMapping, any>> { + const resolverComposition = {}; + const { + userCustomResolverPattern: customResolverQueryPattern, + generatedResolverPattern: generatedResolverQueryPattern, + } = getPathMatcherForRootType("Query", schemaModel); + resolverComposition[`Query.${customResolverQueryPattern}`] = [ + ...queryAndMutationWrappers, + isAuthenticated(["READ"], schemaModel.operations.Query), + ]; + resolverComposition[`Query.${generatedResolverQueryPattern}`] = queryAndMutationWrappers; + + const { + userCustomResolverPattern: customResolverMutationPattern, + generatedResolverPattern: generatedResolverMutationPattern, + } = getPathMatcherForRootType("Mutation", schemaModel); + resolverComposition[`Mutation.${customResolverMutationPattern}`] = [ + ...queryAndMutationWrappers, + isAuthenticated(["CREATE", "UPDATE", "DELETE"], schemaModel.operations.Mutation), + ]; + resolverComposition[`Mutation.${generatedResolverMutationPattern}`] = queryAndMutationWrappers; + + if (isSubscriptionEnabled) { + resolverComposition["Subscription.*"] = subscriptionWrappers; + } + return resolverComposition; +} + +function getPathMatcherForRootType( + rootType: "Query" | "Mutation", + schemaModel: Neo4jGraphQLSchemaModel +): { + userCustomResolverPattern: string; + generatedResolverPattern: string; +} { + const operation = schemaModel.operations[rootType]; + if (!operation) { + return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; + } + const userDefinedFields = Array.from(operation.userResolvedAttributes.keys()); + if (!userDefinedFields.length) { + return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; + } + const userCustomResolverPattern = `{${userDefinedFields.join(", ")}}`; + return { userCustomResolverPattern, generatedResolverPattern: `!${userCustomResolverPattern}` }; +} diff --git a/packages/graphql/src/schema-model/Operation.ts b/packages/graphql/src/schema-model/Operation.ts index 9991145023..ad2440315a 100644 --- a/packages/graphql/src/schema-model/Operation.ts +++ b/packages/graphql/src/schema-model/Operation.ts @@ -26,15 +26,18 @@ export class Operation { public readonly name: string; // only includes custom Cypher fields public readonly attributes: Map = new Map(); + public readonly userResolvedAttributes: Map = new Map(); public readonly annotations: Partial; constructor({ name, attributes = [], + userResolvedAttributes = [], annotations = {}, }: { name: string; attributes?: Attribute[]; + userResolvedAttributes?: Attribute[]; annotations?: Partial; }) { this.name = name; @@ -43,16 +46,32 @@ export class Operation { for (const attribute of attributes) { this.addAttribute(attribute); } + for (const attribute of userResolvedAttributes) { + this.addUserResolvedAttributes(attribute); + } } public findAttribute(name: string): Attribute | undefined { return this.attributes.get(name); } + public findUserResolvedAttributes(name: string): Attribute | undefined { + return this.userResolvedAttributes.get(name); + } + private addAttribute(attribute: Attribute): void { if (this.attributes.has(attribute.name)) { throw new Neo4jGraphQLSchemaValidationError(`Attribute ${attribute.name} already exists in ${this.name}`); } this.attributes.set(attribute.name, attribute); } + + private addUserResolvedAttributes(attribute: Attribute): void { + if (this.userResolvedAttributes.has(attribute.name)) { + throw new Neo4jGraphQLSchemaValidationError( + `User Resolved Attribute ${attribute.name} already exists in ${this.name}` + ); + } + this.userResolvedAttributes.set(attribute.name, attribute); + } } diff --git a/packages/graphql/src/schema-model/OperationAdapter.ts b/packages/graphql/src/schema-model/OperationAdapter.ts index da2fcdcd76..e3d0302f37 100644 --- a/packages/graphql/src/schema-model/OperationAdapter.ts +++ b/packages/graphql/src/schema-model/OperationAdapter.ts @@ -25,11 +25,13 @@ import type { Operation } from "./Operation"; export class OperationAdapter { public readonly name: string; public readonly attributes: Map = new Map(); + public readonly userResolvedAttributes: Map = new Map(); public readonly annotations: Partial; constructor(entity: Operation) { this.name = entity.name; this.initAttributes(entity.attributes); + this.initUserResolvedAttributes(entity.userResolvedAttributes); this.annotations = entity.annotations; } @@ -39,6 +41,12 @@ export class OperationAdapter { this.attributes.set(attributeName, attributeAdapter); } } + private initUserResolvedAttributes(attributes: Map) { + for (const [attributeName, attribute] of attributes.entries()) { + const attributeAdapter = new AttributeAdapter(attribute); + this.userResolvedAttributes.set(attributeName, attributeAdapter); + } + } public get objectFields(): AttributeAdapter[] { return Array.from(this.attributes.values()).filter((attribute) => attribute.isObjectField()); diff --git a/packages/graphql/src/schema-model/generate-model.ts b/packages/graphql/src/schema-model/generate-model.ts index 6cae8e1d15..b930863127 100644 --- a/packages/graphql/src/schema-model/generate-model.ts +++ b/packages/graphql/src/schema-model/generate-model.ts @@ -543,13 +543,23 @@ function generateOperation( definition: ObjectTypeDefinitionNode, definitionCollection: DefinitionCollection ): Operation { - const attributes = (definition.fields || []) + const { attributes, userResolvedAttributes } = (definition.fields || []) .map((fieldDefinition) => parseAttribute(fieldDefinition, definitionCollection)) - .filter((attribute) => attribute.annotations.cypher); - + .reduce<{ attributes: Attribute[]; userResolvedAttributes: Attribute[] }>( + (acc, attribute) => { + if (attribute.annotations.cypher) { + acc.attributes.push(attribute); + } else { + acc.userResolvedAttributes.push(attribute); + } + return acc; + }, + { attributes: [], userResolvedAttributes: [] } + ); return new Operation({ name: definition.name.value, attributes, + userResolvedAttributes, annotations: parseAnnotations(definition.directives || []), }); } diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index f8fe552807..fe5054a7f8 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -347,6 +347,16 @@ function makeAugmentedSchema({ objectComposer.addFields({ [attributeAdapter.name]: { ...composedField, ...customResolver } }); } + + // this is to remove library directives from custom resolvers on root type fields in augmented schema + for (const attributeAdapter of operationAdapter.userResolvedAttributes.values()) { + const composedField = attributeAdapterToComposeFields([attributeAdapter], userDefinedFieldDirectives)[ + attributeAdapter.name + ]; + if (composedField) { + objectComposer.addFields({ [attributeAdapter.name]: composedField }); + } + } }); if (!Object.values(composer.Mutation.getFields()).length) { diff --git a/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts b/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts index 2f2c0aa5b3..095790ae1a 100644 --- a/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts +++ b/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts @@ -145,7 +145,7 @@ function noDirectivesAllowedAtLocation({ } } -/** only the @cypher directive is valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */ +/** only the @cypher and @authentication directives are valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */ function validFieldOfRootTypeLocation({ directiveNode, traversedDef, @@ -161,20 +161,14 @@ function validFieldOfRootTypeLocation({ // @cypher is valid return; } + if (directiveNode.name.value === "authentication") { + // @authentication is valid + return; + } const isDirectiveCombinedWithCypher = traversedDef.directives?.some( (directive) => directive.name.value === "cypher" ); - if (directiveNode.name.value === "authentication" && isDirectiveCombinedWithCypher) { - // @cypher @authentication combo is valid - return; - } // explicitly checked for "enhanced" error messages - if (directiveNode.name.value === "authentication" && !isDirectiveCombinedWithCypher) { - throw new DocumentValidationError( - `Invalid directive usage: Directive @authentication is not supported on fields of the ${parentDef.name.value} type unless it is a @cypher field.`, - [`@${directiveNode.name.value}`] - ); - } if (directiveNode.name.value === "authorization" && isDirectiveCombinedWithCypher) { throw new DocumentValidationError( `Invalid directive usage: Directive @authorization is not supported on fields of the ${parentDef.name.value} type. Did you mean to use @authentication?`, diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index 59ff13f102..27d27e2f97 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -3436,35 +3436,6 @@ describe("validation 2.0", () => { expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@relationship"]); }); - test("@authentication can't be used on the field of a root type", () => { - const doc = gql` - type Query { - someActors: [Actor!]! @authentication - } - - type Actor { - name: String - } - `; - - const executeValidate = () => - validateDocument({ - document: doc, - additionalDefinitions, - features: {}, - }); - - const errors = getError(executeValidate); - - expect(errors).toHaveLength(1); - expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); - expect(errors[0]).toHaveProperty( - "message", - "Invalid directive usage: Directive @authentication is not supported on fields of the Query type unless it is a @cypher field." - ); - expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@authentication"]); - }); - test("@authorization can't be used on the field of a root type", () => { const doc = gql` type Query { @@ -3597,6 +3568,27 @@ describe("validation 2.0", () => { expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@populatedBy"]); }); + test("@authentication ok to be used on the field of a root type", () => { + const doc = gql` + type Query { + someActors: [Actor!]! @authentication + } + + type Actor { + name: String + } + `; + + const executeValidate = () => + validateDocument({ + document: doc, + additionalDefinitions, + features: {}, + }); + + expect(executeValidate).not.toThrow(); + }); + test("@authentication with @cypher ok to be used on the field of a root type", () => { const doc = gql` type Query { diff --git a/packages/graphql/src/translate/authorization/check-authentication.ts b/packages/graphql/src/translate/authorization/check-authentication.ts index 767892425e..ec0cafa6b1 100644 --- a/packages/graphql/src/translate/authorization/check-authentication.ts +++ b/packages/graphql/src/translate/authorization/check-authentication.ts @@ -25,6 +25,7 @@ import type { } from "../../schema-model/annotation/AuthenticationAnnotation"; import { applyAuthentication } from "./utils/apply-authentication"; import type { Neo4jGraphQLTranslationContext } from "../../types/neo4j-graphql-translation-context"; +import type { Operation } from "../../schema-model/Operation"; export function checkAuthentication({ context, @@ -65,12 +66,7 @@ export function checkEntityAuthentication({ }) { const schemaLevelAnnotation = context.schemaModel.annotations.authentication; if (schemaLevelAnnotation) { - const requiresAuthentication = targetOperations.some( - (targetOperation) => schemaLevelAnnotation && schemaLevelAnnotation.operations.has(targetOperation) - ); - if (requiresAuthentication) { - applyAuthentication({ context, annotation: schemaLevelAnnotation }); - } + applyAuthentication({ context, annotation: schemaLevelAnnotation, targetOperations }); } const annotation: AuthenticationAnnotation | undefined = field @@ -78,11 +74,29 @@ export function checkEntityAuthentication({ : entity.annotations.authentication; if (annotation) { - const requiresAuthentication = targetOperations.some( - (targetOperation) => annotation && annotation.operations.has(targetOperation) - ); - if (requiresAuthentication) { - applyAuthentication({ context, annotation }); - } + applyAuthentication({ context, annotation, targetOperations }); } } + +export const isAuthenticated = + (targetOperations: AuthenticationOperation[], entity: Operation | undefined) => + (next) => + (root, args, context, info) => { + const schemaLevelAnnotation = context.schemaModel.annotations.authentication; + if (schemaLevelAnnotation) { + applyAuthentication({ context, annotation: schemaLevelAnnotation, targetOperations }); + } + + if (entity) { + const { fieldName } = info; + const annotation: AuthenticationAnnotation | undefined = + entity.annotations.authentication || + (fieldName && entity.findUserResolvedAttributes(fieldName)?.annotations.authentication); + + if (annotation) { + applyAuthentication({ context, annotation, targetOperations }); + } + } + + return next(root, args, context, info); + }; diff --git a/packages/graphql/src/translate/authorization/utils/apply-authentication.ts b/packages/graphql/src/translate/authorization/utils/apply-authentication.ts index d2b66bd980..8ef9cb8a7c 100644 --- a/packages/graphql/src/translate/authorization/utils/apply-authentication.ts +++ b/packages/graphql/src/translate/authorization/utils/apply-authentication.ts @@ -21,15 +21,26 @@ import { Neo4jGraphQLError } from "../../../classes"; import { AUTHORIZATION_UNAUTHENTICATED } from "../../../constants"; import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { filterByValues } from "./filter-by-values"; -import type { AuthenticationAnnotation } from "../../../schema-model/annotation/AuthenticationAnnotation"; +import type { + AuthenticationAnnotation, + AuthenticationOperation, +} from "../../../schema-model/annotation/AuthenticationAnnotation"; export function applyAuthentication({ context, annotation, + targetOperations, }: { context: Neo4jGraphQLTranslationContext; annotation: AuthenticationAnnotation; + targetOperations: AuthenticationOperation[]; }): void { + const requiresAuthentication = targetOperations.some((targetOperation) => + annotation.operations.has(targetOperation) + ); + if (!requiresAuthentication) { + return; + } if (!context.authorization.isAuthenticated) { throw new Neo4jGraphQLError(AUTHORIZATION_UNAUTHENTICATED); } diff --git a/packages/graphql/src/translate/translate-top-level-cypher.ts b/packages/graphql/src/translate/translate-top-level-cypher.ts index 0d80933c72..fe72291c78 100644 --- a/packages/graphql/src/translate/translate-top-level-cypher.ts +++ b/packages/graphql/src/translate/translate-top-level-cypher.ts @@ -26,6 +26,7 @@ import type { Neo4jGraphQLTranslationContext } from "../types/neo4j-graphql-tran import { applyAuthentication } from "./authorization/utils/apply-authentication"; import { QueryASTContext, QueryASTEnv } from "./queryAST/ast/QueryASTContext"; import { QueryASTFactory } from "./queryAST/factory/QueryASTFactory"; +import type { AuthenticationOperation } from "../schema-model/annotation/AuthenticationAnnotation"; const debug = Debug(DEBUG_TRANSLATE); @@ -51,7 +52,10 @@ export function translateTopLevelCypher({ const annotation = operationField.annotations.authentication; if (annotation) { - applyAuthentication({ context, annotation }); + const targetOperations: AuthenticationOperation[] = + type === "Query" ? ["READ"] : ["CREATE", "UPDATE", "DELETE"]; + + applyAuthentication({ context, annotation, targetOperations }); } const { resolveTree } = context; diff --git a/packages/graphql/tests/integration/issues/3746.int.test.ts b/packages/graphql/tests/integration/issues/3746.int.test.ts new file mode 100644 index 0000000000..944ff05d4b --- /dev/null +++ b/packages/graphql/tests/integration/issues/3746.int.test.ts @@ -0,0 +1,465 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import Neo4jHelper from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; +import { createBearerToken } from "../../utils/create-bearer-token"; + +describe("https://github.com/neo4j/graphql/issues/3746", () => { + let driver: Driver; + let neo4j: Neo4jHelper; + const secret = "secret"; + + beforeAll(async () => { + neo4j = new Neo4jHelper(); + driver = await neo4j.getDriver(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should apply field-level authentication to root field on Query - pass", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User @authentication(operations: ["READ"]) + } + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const token = createBearerToken(secret, { sub: userId }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ token }), + }); + + expect(gqlResult.errors).toBeUndefined(); + expect((gqlResult.data as any).me.customId).toEqual(userId); + }); + + test("should apply field-level authentication to root field on Query - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User + } + `; + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply type-level authentication to root field on Query - pass", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query @authentication(operations: ["READ"]) { + me: User + you: User + } + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const token = createBearerToken(secret, { sub: userId }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ token }), + }); + + expect(gqlResult.errors).toBeUndefined(); + expect((gqlResult.data as any).me.customId).toEqual(userId); + }); + + test("should apply type-level authentication to root field on Query - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query @authentication(operations: ["READ"]) { + me: User + you: User + } + `; + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply field-level authentication to root field on Mutation - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User + } + + type Mutation { + updateMe(id: ID): User @authentication(operations: ["CREATE"]) + } + `; + + const query = /* GraphQL */ ` + mutation { + updateMe(id: 3) { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + Mutation: { updateMe: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply type-level authentication to root field on Mutation - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User + } + + type Mutation @authentication(operations: ["CREATE"]) { + updateMe(id: ID): User + } + `; + + const query = /* GraphQL */ ` + mutation { + updateMe(id: 3) { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + Mutation: { updateMe: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply schema-level defined authentication to root field on Query - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User + you: User + } + + extend schema @authentication(operations: ["READ"]) + `; + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply schema-level defined authentication to root field on Query - pass", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User + you: User + } + + extend schema @authentication(operations: ["READ"]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const token = createBearerToken(secret, { sub: userId }); + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ token }), + }); + + expect(gqlResult.errors).toBeUndefined(); + expect((gqlResult.data as any).me.customId).toEqual(userId); + }); + + test("should apply schema-level defined authentication to root field on Mutation - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User + you: User + } + + type Mutation { + updateMe(id: ID): User + } + + extend schema @authentication(operations: ["UPDATE"]) + `; + + const query = /* GraphQL */ ` + mutation { + updateMe(id: 3) { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + Mutation: { updateMe: () => ({}) }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); +});