Creating an Authorization Plugin for Apollo Server

thomasstep

Thomas Step

Posted on August 11, 2020

Creating an Authorization Plugin for Apollo Server

Originally published at https://thomasstep.dev/blog/creating-an-authorization-plugin-for-apollo-server

While working on my side project I came across a use case for needing authorization in place for all of my various GraphQL queries and mutations. For the sake of this post, I will use an example of a library where certain users are allowed to create, read, and update books (I might not get that far into it, but we will see what happens). As a library of high esteem, we do not want to let just anyone be able to operate on the books. This will pretty much just be an extension of the first example given on Apollo Server's website. I do have working code that you are welcome to reference while you read through the article.

I had learned about plugins for Apollo a little while back and I had minor exposure to creating them. They are pretty nifty now that I have used them a little more extensively. The whole idea is that you can trigger certain logic based on events. The only catch for me was how you filter down to a particular event. Apollo has a flow chart that on their website that can help you figure out exactly how the events get fired off. You'll notice in that flow chart that requestDidStart is boxed in pink as opposed to the purple of the other events. That's because requestDidStart is special. Every plugin must first return requestDidStart and then return whatever event underneath requestDidStart that it wants to be triggered by. It's weird and it took me a minute to wrap my head around. I'm going to go ahead and dive into some code but come back here after you read the code to make sure you understand what's going on.

function authPlugin() {
  return {
    requestDidStart(requestContext) {
      const {
        context: apolloContext,
        request: {
          variables: requestVariables,
        },
      } = requestContext;

      return {
        didResolveOperation(resolutionContext) {
          const { user } = apolloContext;

          resolutionContext.operation.selectionSet.selections.forEach((selection) => {
            const { value: operationName } = selection.name;
            console.log(user);
            console.log(operationName);
          });
        },
      };
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

This is the beginning of my auth plugin. Like I said before this returns requestDidStart and requestDidStart returns the other event(s) that I want to act on, which is only didResolveOperation for this plugin. Within requestDidStart, you have the opportunity to pull out some special information from the caller. You can grab the context created when you created the server and you can grab the variables sent with the request. I'll go ahead and show you how I am initializing the server, so you can just copy and paste if you want to follow along.

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  context: (ctx) => {
    ctx.user = 'J. R. R. Tolkien';
    return ctx;
  },
  plugins: [
    authPlugin,
  ],
});

apolloServer.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});
Enter fullscreen mode Exit fullscreen mode

You can create a list of plugins so feel free to break them up as you see fit.

If you have been following along so far and you have started based on the Apollo tutorial I linked at the beginning, then you should be able to query your server and see the logs for the context's user as J. R. R. Tolkien and the operationName as books. Now that we have a plugin set up that can be triggered off of whatever gets passed in, let's start adding in some authorization logic. To keep the post centered around plugins and the authorization logic, I am going to move forward with the same book query and just hardcode different context.users in order to test. In addition, I will use a query called parrot that returns the string that you pass in as a parameter called word to show some additional information that you can pull out of the plugins. The resolver code for that looks like parrot: (parent, args) => args.word, just paste that into the resolvers.Query object that is given in the Apollo tutorial and add parrot(word: String!): String! to the typeDefs.

Now that we have two queries, I want to authorize only J. R. R. Tolkien to access the books query and allow anyone to acccess the parrot query. To do that I am going to create a mapping from different operations to different authorization logic functions. I will use a function called endpointAuth to do that. I will also create two helping functions for the authorization logic called booksAuth and parrotAuth.

const { AuthenticationError } = require("apollo-server");

function booksAuth(user) {
  const validUsers = ['J. R. R. Tolkien'];

  if (validUsers.includes(user)) return;

  throw new AuthenticationError('You are not authorized to use this endpoint.');
}

function parrotAuth() {
  return;
}

function endpointAuth(endpoint, user) {
  switch (endpoint) {
    case 'books':
      booksAuth(user);
      break;

    case 'parrot':
      parrotAuth();
      break;

    default:
      throw new AuthenticationError('Unknown endpoint.');
  }
}
Enter fullscreen mode Exit fullscreen mode

If you try using the endpoints, you should be allowed to, but if you change the hardcoded J. R. R. Tolkien name in the context to something else, the AuthenticationError will be thrown stopping the execution. Since this all runs before any resolver logic, you can stop a user before they use a particular endpoint they are not supposed to. Of course, for this to make the most sense, I suggest querying your database while building the context to get the actual user's information before this is run. Either way, we now know how to stop someone from querying something that we do not want them to. This is the main point that I wanted to get across. As a bonus, I will show you how to create a scaffolding for logic based on the input given.

Let's say that someone is querying parrot, but we only want to support a given whitelist of words that are allowed to be echoed. I'm thinking of a query that looks something like this:

query parrotQuery(
  $word: String!
) {
  parrot(word: $word)
}

variables: {
  "word": "badword"
}
Enter fullscreen mode Exit fullscreen mode

We will need to first do some work before we ever call parrotAuth to make sure that we have the correct input. There are some weird structures that get passed down to the plugins that I ended up logging to make sense of. I am going to spare you that trouble and go ahead and just show the functions I have already created to parse out all that madness. They are called flattenArgs and handleValue. The flattenArgs function will loop through the arguments passed in and then called handleValue where appropriate. The handleValue function either can do some sort of data transformation on a specific type (like casting from a string to a number for IntValue) or map a variable value to the appropriate given value. Here is the code to do that.

function handleValue(argValue, requestVariables) {
  const {
    kind,
  } = argValue;
  let val;

  switch (kind) {
    case 'IntValue':
      val = argValue.value;
      break;

    case 'StringValue':
      val = argValue.value;
      break;

    case 'Variable':
      val = requestVariables[argValue.name.value];
      break;

    default:
      // If I haven't come across it yet, hopefully it just works...
      val = argValue.value;
      break;
  }

  return val;
}

function flattenArgs(apolloArgs, requestVariables) {
  const args = {};

  apolloArgs.forEach((apolloArg) => {
    console.log(JSON.stringify(apolloArg, null, 2));
    const {
      kind,
      name: {
        value: argName,
      },
      value: argValue,
    } = apolloArg;

    switch (kind) {
      case 'Argument':
        args[argName] = handleValue(argValue, requestVariables);
        break;

      default:
        break;
    }
  });

  return args;
}
Enter fullscreen mode Exit fullscreen mode

Also I changed the authPlugin function to format and then pass these values on. It now looks like this.

function authPlugin() {
  return {
    requestDidStart(requestContext) {
      const {
        context: apolloContext,
        request: {
          variables: requestVariables,
        },
      } = requestContext;

      return {
        didResolveOperation(resolutionContext) {
          const { user } = apolloContext;

          resolutionContext.operation.selectionSet.selections.forEach((selection) => {
            const { value: operationName } = selection.name;
            const args = flattenArgs(selection.arguments, requestVariables);
            endpointAuth(operationName, user, args);
          });
        },
      };
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

I can pass those args down to parrotAuth and make sure that a user is allowed to call the query with those specific args.

function parrotAuth(user, args) {
  const validUsers = ['J. R. R. Tolkien'];
  const dictionary = ['Frodo', 'Gandalf', 'Legolas'];

  if (validUsers.includes(user) && dictionary.includes(args.word)) return;

  throw new AuthenticationError('You are not authorized to use that word.');

  return;
}

function endpointAuth(endpoint, user, args) {
  switch (endpoint) {
    case 'books':
      booksAuth(user);
      break;

    case 'parrot':
      parrotAuth(user, args);
      break;

    default:
      throw new AuthenticationError('Unknown endpoint.');
  }
}
Enter fullscreen mode Exit fullscreen mode

The authorization logic itself is not great and only for example purposes because it is all hardcoded. I have used this in my project to pull in the user, pull in the arguments, and make sure that the user can act on the given arguments. One use case could be having a randomly generated GUID represent a book and the user that is passed in from the context could also have a list of books that the user is allowed to operate on. You could check the arguments to make sure that the given GUID is present in the array of books for authorized operation. This can get more dynamic once you hook in a database and API calls to add books to a user's list of authorized-to-operate-on books.

The main goal of this was mostly to get code snippets out there to show how to create Apollo plugins, how to parse through the input given to the plugins, and a brief overview of how you could build a scaffold around authorization logic. As I said, I have used this with success, and I hope you can too.

💖 💪 🙅 🚩
thomasstep
Thomas Step

Posted on August 11, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related