(GraphQl) exceptions to globalAuthentication for custom resolvers?

Hi everyone.

I'm currently developping a graphql server for a neo4j database that will be using a few custom resolvers.

Basically, all but very few of the operations on the server will require user to be authenticated (including reading any content). Of course the very few exceptions will be login and signup.

Is there a solution to set globalAuthentication: true in Neo4jGraphQLAuthJWTPlugin while also be able to define some exceptions to it ? As the doc says, it is not possible to pass an @auth directive to a custom resolver.

    const neo4jgraphql = new Neo4jGraphQL (
    {
        typeDefs, 
        resolvers,
        driver: neo4jdriver,
        plugins: 
        {
            subscriptions: new Neo4jGraphQLSubscriptionsSingleInstancePlugin(),
            auth: new Neo4jGraphQLAuthJWTPlugin(
            {
                secret: process.env.JWT_SESSION_KEY,
                globalAuthentication: true,
            }),
        },
    })

This would spare me from having to (and forget to !!!) define "allowUnauthenticated: false" in every @auth clause.

Reading the "auth and custom resolvers" page made me think I just had to verify the JWT's content if a user has to be authenticated to access a custom resolvers, but if globalAuthentication is set to true, this assertion is obviously false.

Thanks for your help.

Hi @ankou29666! When you say it is possible to pass an @auth directive to a custom resolver, are you talking about the fact it is not possible to use both @auth and @customResolver/@comupted on the same field?

If that's the case, we are currently working on changes to the @auth directive and could consider adding the ability to turn disable the requirement for authentication on @customResolver fields. For now you could consider using graphql-shield to enable/disable the requirement for authentication where required. I'm not super familiar with the tool so there may be a better way of doing this but below is an example that requires authentication everywhere, unless only a specific field (that uses a custom resolver) is requested:

const typeDefs = gql`
  type Episode {
    runtime: Int!
    series: Series! @relationship(type: "HAS_EPISODE", direction: IN)
  }

  interface Production {
    title: String
    actors: [Actor!]!
      @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn")
  }

  type Movie implements Production {
    id: Int
    title: String
    actors: [Actor!]!
      @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN)
  }

  type Series implements Production @exclude {
    title: String!
    episodes: [Episode!]! @relationship(type: "HAS_EPISODE", direction: OUT)
    actors: [Actor!]!
      @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn")
  }

  interface ActedIn @relationshipProperties {
    screenTime: Int
  }

  type Actor {
    customResolverField: Float @customResolver
    name: String
    actedIn: [Production!]!
      @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT)
  }
`;

const isAuthenticated = rule()(async (parent, args, ctx, info) => {
  const authorizationHeader =
    ctx.req?.headers?.authorization ||
    ctx.req?.headers?.Authorization ||
    ctx.req.cookies?.token;
  const token = authorizationHeader.split("Bearer ")[1];
  const verify = jsonwebtoken.verify(token, "secret", {
    algorithms: ["HS256", "RS256"],
  });
  return verify.sub !== undefined;
});

const permissions = shield({
  Query: {
    "*": isAuthenticated,
    actors: allow,
  },
  Actor: {
    "*": isAuthenticated,
    customResolverField: allow,
  },
});

const resolvers = {
  Actor: {
    customResolverField() {
      return Math.random();
    },
  },
};

const driver = neo4j.driver("url", neo4j.auth.basic("user", "password"));

const neoSchema = new Neo4jGraphQL({
  typeDefs,
  resolvers,
  driver,
});

async function main() {
  let schema = await neoSchema.getSchema();
  schema = applyMiddleware(schema, permissions);
  const server = new ApolloServer({
    schema,
    context: ({ req }) => ({ req }),
  });
  const url = await server.listen();
  console.log(`🚀 Server ready at ${url.url}`);
}

main();

Hi and thanks for your answer.

I'm really sorry for that confusion, by custom resolver I actually mean my own mutation resolvers (with OGM driver of course), nothing to deal with the @customresolver directive.

For login and signup I created one mutation for each and all I would like is at least these two homemade mutations to be excepted by globalAuthentication.

I declared these mutations in my typedefs and of course have declared the corresponding resolvers. What I would like is to define exception to globalAuthentication for self-created queries and mutations, and if possible to only those two firsts.

export const typeDefs = gql`
    type Mutation 
    {
        userSignUp (email: String!, phone: String!, password: String!): USER
        userLogin (email: String!, password: String!): USER
        updateProfile (lastName: String!, firstName: String!, dob: Date!, gender: GENDER!): USER
        sendNewPasswordByEmail (email: String!) : String
        # sendNewPasswordBySMS (phone: String!) : String
        sendEmailVerificationLink: String
        #sendPhoneVerificationLink: String
    }

    # DELETE FORBIDDEN, CREATE and UPDATE BY CUSTOM RESOLVERS ONLY
    type USER @exclude(operations: [CREATE, UPDATE, DELETE])
    {
        ...
    }
}

Is your problem seeing the following error when you try to apply the @auth directive to your mutations?

Unknown directive "@auth"

what I tried and what I want to achieve would be something like this

    type Mutation 
    {
        userSignUp (email: String!, phone: String!, password: String!): USER @auth (allowUnauthicated: true) 
# Or something like that, I don't remember what I tried exactly
    }

Yeah, it looks like we don't currently support doing that. As I said, we're currently looking at making changes to the @auth directive so we'll consider allowing this natively going forwards.

For now you could consider using graphql-shield to achieve what you want. If you wanted to use the type defs you've just provided with the code I provided earlier, rules such as the following should achieve what you are looking for:

const permissions = shield({
  Query: {
    "*": isAuthenticated,
  },
  Mutation: {
    "*": isAuthenticated,
    userSignUp: allow,
    userLogin: allow,
  }
});
1 Like

Thanks for the tip, I'll try this. I keep you informed.

Ok, this worked, well not exactly as i'm now facing a new problem

so as I expected, graphql-shield acts as a middleware between the graphql server (apollo, yoga, mercurius or whatsoever) and neo4j's graphql layer.
So we have to auth with middleware instead of neo4j's auth plugin

but let's start with a super video tutorial from Jamie Barton that I invite anyone interested to watch first

So let's assume that we use neo4j's JWT auth plugin
let's create the shield this way :

// shield.js
import { shield, rule, not, allow } from "graphql-shield"
// IMPORTANT NOTICE : the basic example from their doc forgets to import the 
// allow, deny, and, or, not keywords. rules are async, hence return promises. Obviously 
// logical operators don't work on promises, hence I guess the role of those operators

import { GraphQLError } from "graphql"
import jwt from "jsonwebtoken"

const isAuthenticated = rule()(async function (parent, args, context, info)
{
    console.log ("isAuthenticated / auth header : " + context.req.headers.authorization)
    if (!context.req.headers.authorization) 
        return new GraphQLError ("Unauthenticated (no auth header)", { extensions: { code: 'UNAUTHENTICATED' } })
    
    const token =  context.req.headers.authorization.split(' ')[1]
    console.log ("isAuthenticated / token : " + token)

    if (!token) 
        return new GraphQLError ("Unauthenticated (no token)", { extensions: { code: 'UNAUTHENTICATED' } })
    
    try
    {
        const decode = jwt.verify(token, process.env.JWT_SESSION_KEY)
        // Here is our problem, obviously neo4j sets context.auth even if no auth plugin has been set
        context.auth = 
        {
            isAuthenticated: true,
            roles: [],
            jwt: decode,
        }
        console.log ("isAuthenticated / auth.jwt : " + JSON.stringify (context.auth, null, 4))
        return true
    }
    catch (err)
    {
        console.log (error)
        return new GraphQLError ("Unauthenticated", { extensions: { code: 'INVALID_TOKEN' } })
    } 

    return new GraphQLError ("we should not get here")
})

const isUnauthenticated = rule()(async function (parent, args, context, info)
{
    console.log ("isUnauthenticated context keys : " + Object.keys(context.req.headers))
    if (!context.req.headers.authorization) 
        return true
    
    return new GraphQLError ("already Authenticated", { extensions: { code: 'ALREADY_AUTHENTICATED' } }) 
})

const permissions = shield({
    Query: { "*": isAuthenticated, },
    Mutation:
    {
        "*": isAuthenticated,
        login: isUnauthenticated,
        signup: isUnauthenticated,
    },
    Subscription: { "*": isAuthenticated, },
})

export default permissions

The important thing to note is that graphql-shield by default catches ALL errors thrown to throw their default one instead. So ALWAYS return an error instead of throwing it.
see documentation
So to debug the code you basically have to put a console.log between each line to see where the print stops to locate the error.

So I created an isUnauthenticated (I won't allow a user to log in if he sends me a token) one because I either return true, or an error, so negating the true would return the default error.

Next is the application of the middleware to the shema.

const neo4jgraphql = new Neo4jGraphQL (
{
    typeDefs, 
    resolvers,
    driver: neo4jdriver,
    plugins: 
    {
        // TODO : replace default subscription plugin by custom redis plugin
        subscriptions: new Neo4jGraphQLSubscriptionsSingleInstancePlugin(),

        // Don't remove this, neo4j has to perform authentication again to get context.auth properly set ... again
        auth: new Neo4jGraphQLAuthJWTPlugin(
        {
            secret: process.env.JWT_SESSION_KEY,
            globalAuthentication: false,
        }),
    },
})

const neo4jschema = await neo4jgraphql.getSchema()

const neo4jschemaWithShield = applyMiddleware (neo4jschema, permissions)

and finally use the shielded shema instead of the raw schema (with Fastify and Mercurius)

app.register(mercurius,
    {
        schema: neo4jschemaWithShield,
        subscription: { pubsub },
        graphiql: environment==='development',
        context: (req, res) => { return { req, res, pubsub, serverUrl, dataModel} }
    })

In apollo without an express middleware this would be

const server = new ApolloServer(
{
    schema: neo4jschemaWithShield
});

const { url } = await startStandaloneServer(server, { listen: { port: 4000 },
});

For authorizations, this is strongly gonna be relationship dependant in my case which is the reason why I just content myself with allowing login and signup to unauthenticated and allowing the remainder to authenticated.

Thanks Liam for the suggestion.