Graphql Directives

Graphql Directives

For the past few weeks I have been diving more and more into graphql. One of the things I learned about was Graphql directives. The very first thing I thought of once I understood the concept of them was Auth. Given that Apollo Federation is on the horizon I got really excited knowing that we could have a centralized directive just for authenticating a JWT sent by a user or service. And here is how it didn't work out that way.

Vision

When I originally heard about directives I got the idea that we could build an `@auth` directive that could enforce auth on mutations, queries, responses and inputs.

Concerns

The concerns I read about was that you shouldn't create an auth directive because the directive definition can come back in introspection. I think that warning was mostly so people don't put their validation logic inside the directive itself. Which built correctly, you could see that we check for roles or that the user exists but other than that. No harm done.

Implementation

First off implement your apollo server.

new ApolloServer({
  typeDefs: '...',
  resolvers: {/*...*/},
  context() { 
    return {};
  }
});

Next we need to create a function that would return an authenticated user on the context:

function getUser(headers) {
  if(headers.authorization){
    return user; // this is where your jwt validation would occur
  }
  
  return null;
}

new ApolloServer({
  typeDefs: '...',
  resolvers: {/*...*/},
  schemaDirectives: {/*...*/},
  context({ req }) {
    return {
      ...req,
      user: getUser(req.headers),
    };
  }
});

Now that we have the context we can create the directive and directive definition:

enum Role {
  API_USER
  SERVICE
}

directive @auth(role: Role!) on FIELD_DEFINITION | OBJECT
const { DirectiveLocation, GraphQLDirective } = require('graphql');
const { SchemaDirectiveVisitor } = require('graphql-tools');

class AuthDirective extends SchemaDirectiveVisitor {
  static getDirectiveDeclaration(directiveName, schema) {
    return new GraphQLDirective({
      name: 'auth',
      locations: [
        DirectiveLocation.FIELD_DEFINITION, 
        DirectiveLocation.OBJECT
      ],
      args: {
        role: {
          type: schema.getType('Role')
        }
      }
    });
  }

  visitFieldDefinition(field) {
    this.wrapField(field);
  }

  visitObject(type) {
    this.wrapObject(type);
  }

  wrapField(field) {
    const { resolve: defaultResolver } = field;
    const { role } = this.args;

    field.resolve = async function(...args) {
      if (!role) {
        return defaultResolver.apply(this, args);
      }

      const context = args[2];

      if (!context.auth) {
        throw new Error('Not Authorized');
      }

      if (role !== context.user.role) {
        throw new Error(`Not enough permissions`);
      }

      return defaultResolver.apply(this, args);
    };
  }

  wrapObject(obj) {
    Object.entries(obj.getFields()).forEach(function([, value]) {
      this.wrapField(value);
    });
  }
}

Now given this, we can now use this on mutations, and queries.

type Mutation {
  pingSecure(): PingResponse @auth(role: API_USER)
  pingInsecure(): PingResponse
}

pingSecure will need a user that has the role of API_USER where as pingInsecure will not require any kind of authentication. The nice thing about this is that now the authentication can be declared in my schema. It easily documents what mutations and queries require what role. You  could go further and add a groups or scopes directive to define what kind of write permissions would be required for those mutations as well.

What about inputs?

Ah, maybe you noticed I haven't touched on input types yet. The not so cool thing about input type directives is that you don't have access to the request context at the point of execution. In fact, when you create an input type directive you are more likely to create your own scalar type. Input type directives are mostly for manipulating or validating the format of the input properties.

So while I got excited about being able to create a mutation like:

input UpdateInput {
  username: String!
  role: Role! @auth(role: SERVICE)
}

type Mutation {
  updateUser(input: UpdateInput): Response @auth(role: API_USER)
}

I was hoping to be able to restrict the valid inputs based on role. This could be a lot more declarative and I don't have to implement a different mutation or pollute my resolvers with additional auth checking.

Maybe I'll figure out a better pattern in the future. But at least right now  I am disappointed slightly. While it's a bummer right now, I am definitely going to use the auth directive more.