DEV Community

loading...

Using GraphQL schema directives for role based authorization

Tushar Khubani
A passionate web developer, keen about learning always.
・3 min read

When working on a graphql based api backend using node.js, if you stumble across role based authorization there are plenty ways of authorizing the logged in user.

You could keep the authorization logic completely separate from graphql(in a controller), you could write the logic in the resolvers itself(increases the amount of code in resolvers) or to keep the code clean and understandable, write GraphQL custom schema directives.

So here is how you would write a custom schema directive in graphql, for authorizing particular roles.

//HasRoleDirective.js

import { SchemaDirectiveVisitor } from "apollo-server-express";
import {
  GraphQLDirective,
  DirectiveLocation,
  GraphQLList,
  defaultFieldResolver
} from "graphql";
import { ensureAuthenticated } from "../controllers/authController";
import { AuthorizationError } from "../errors";

class HasRoleDirective extends SchemaDirectiveVisitor {
  static getDirectiveDeclaration(directiveName, schema) {
    return new GraphQLDirective({
      name: "hasRole",
      locations: [DirectiveLocation.FIELD_DEFINITION],
      args: {
        roles: {
          type: new GraphQLList(schema.getType("Role"))
        }
      }
    });
  }
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const roles = this.args.roles;
    field.resolve = async function(...args) {
      const [, , context] = args;
      await ensureAuthenticated(context);
      const userRoles = context.me.role;

      if (roles.some(role => userRoles.indexOf(role) !== -1)) {
        const result = await resolve.apply(this, args);
        return result;
      }
      throw new AuthorizationError({
        message: "You are not authorized for this resource"
      });
    };
  }
}
export default HasRoleDirective;

First we declare the directive name and the valid arguments that it accepts, when accepting arguments.
later in the field definition visitFieldDefinition(field), where the logic is to be described, we accept arguments, extract contexrt from the args, the function call ensureAuthenticated(context) is to veirfy the jwtToken from the context, I have included the user's role in the jwt token.
So the HasRole directive is declared and ready to use. To use you need to pass the directives to your graphql config and declare it in the typeDefinition as follows

// GraphQL Config
const schemaDirectives = { hasRole: HasRoleDirective };
const server = new ApolloServer({
  typeDefs,
  resolvers,
  schemaDirectives,
  ...context
});

//typedefinitions
import { gql } from "apollo-server-express";
export default gql`
  directive @hasRole(roles: [String!]) on FIELD_DEFINITION | FIELD
  scalar Date

  type Query {
    _: String
  }
  type Mutation {
    _: String
  }
`;

this way you will be able to use the custom schema directive in your typeDefs
example on how to use the custom schema directive:

import { gql } from "apollo-server-express";

export default gql`
  extend type Query {
    businesses: [Business!] @hasRole(roles: [THIS_SUPER_ADMIN])
    business(id: ID!): Business @hasRole(roles: [THIS_ADMIN, THIS_SUPER_ADMIN])
  }
  extend type Mutation {
    businessUpdate(name: String!): Business!
      @hasRole(roles: [THIS_ADMIN, THIS_SUPER_ADMIN])
  }
  type Business {
    id: ID!
    name: String!
  }
`;

Another example, to verify if a user is authenticated

//AuthDirective.js
import { SchemaDirectiveVisitor } from "apollo-server-express";
import { defaultFieldResolver } from "graphql";
import { ensureAuthenticated } from "../controllers/authController";

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;

    field.resolve = async function(...args) {
      const [, , context] = args;
      await ensureAuthenticated(context);
      return resolve.apply(this, args);
    };
  }
}
export default AuthDirective;
//passing to graphql config
const schemaDirectives = { auth: AuthDirective };
const server = new ApolloServer({
  typeDefs,
  resolvers,
  schemaDirectives,
  ...context
});

//declaration in typeDefinitions
import { gql } from "apollo-server-express";
export default gql`
  directive @auth on FIELD_DEFINITION
  scalar Date

  type Query {
    _: String
  }
  type Mutation {
    _: String
  }
`;
//typedefinitions usage
`extend type Query {
    payments(
      customer: ID
      status: String
      cursor: String
      limit: Int
    ): PaymentCollection! @auth
  }
  `

If you need to apply more than one directive to a query/mutation NOTE: the direction you write directives in graphql queries is from right to left, the right most directive is resolved first then the one to its left is resloved.
so say if you have this

`extend type Query {
    payments(
      customer: ID
      status: String
      cursor: String
      limit: Int
    ): PaymentCollection! @hasRole(roles: [THIS_ADMIN, THIS_SUPER_ADMIN]) @auth
  }`

the auth directive is resolved first and the hasRoles is resolved after the auth directive. Say the auth directive fails the hasRoles directive is never reached.


I'm still learning a lot in GraphQL, if there are any improvements or mistakes in the code above please do drop a comment,
its always better to learn from the mistakes we make :)


Discussion (8)

Collapse
goranpaunovic profile image
Goran Paunović

Thank you so much :-).
I was trying to make a custom authorization directive where the result depends on query arguments. I was strugling until I found your article. After that, everything worked :-).
Cheers!

Collapse
mucorolle profile image
Muco Rolle Tresor • Edited

Hello Tushar thank you for your article it's really helpfull I was wondering how can we make a custom directive which can ben add on definifination declaration? like this

type Query @auth  {
    posts: [Post!]!
}

type Mutation @auth {
     createPost(post: CreatePostInput): Post!
}

All fields which are in Query and Mutation can then be protect from users who are not logged in.

Collapse
michelemassari profile image
Michele Massari

Hi! Thanks so much for this, it's a great start. One question: what do you do in the authController?

Would it be possible to see the whole authentication process?
At the moment I have one middleware that reads the token with express-jwt and decodes the user to the context but I have no idea if that's the best way to do it.

A more extensive example would be great

Collapse
tushark1 profile image
Tushar Khubani Author • Edited

In the auth controller I have the logic for authenticating, example validating credentials, if credentials match, return a token, get the auth user (decode jwt token) etc.
How I set the user context is as follows

import { getMe } from "../controllers/authController";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  schemaDirectives,
  context: async ({ req }) => {
    const me = await getMe(req);
    return { me };
  }
});

and the getMe logic in auth controller is as following:

export const getMe = async req => {
  const token = req.headers["x-access-token"];
  if (token) {
    try {
      return await jwt.verify(token, JWT_SECRET);
    } catch (error) {
      throw new AuthenticationError("Session expired, please login!", error);
    }
  }
};

jwt.verify() returns the payload if the jwt token is valid and not expired.

Collapse
michelemassari profile image
Michele Massari

Amazing, thanks so much for clarifying that

Collapse
zspencer profile image
Zee

This is super great Tushar! I'm about to implement a pundit-esque interface on top of apollo-server and this helped me think through some of the things I was struggling with.

Collapse
tushark1 profile image
Tushar Khubani Author

So glad this helped you :)

Collapse
pouyajabbari profile image
Pouya Jabbarisani • Edited

I've implemented my directive but it's not working. even console.log not working in visitFieldDefinition method.
I've checked my filed and code syntax everything seems good. 🤯