For a while now GraphQL has been a regular topic at programming conferences. While I am a big fan of REST APIs, the touted benefits of GraphQL are appealing. I won’t go into the REST vs GraphQL debate on this post because that’s a post all of it’s own.
In short, I have worked on very big REST API with many endpoints that added to a lot of developer overhead. Since GraphQL promises a single endpoint for the client to request the desired payload, I could see how it can lead to efficiencies for computing resources and developers.
A little while ago our developer team kicked around the idea of adopting a GraphQL api. During that time I started to research more about GraphQL. While there are many posts covering GraphQL, I didn’t find a lot on Authentication, and Authorization patterns. In this post I will cover what I learned about Authentication / Authorization with Absinthe and GraphQL.
Overview
In order to implement a GraphQL api, the spec must be followed. The easiest way to build out a GraphQL api in Elixir is to use the well featured Absinthe library. While we could use Absinthe alone, it’s best to use Absinthe Plug since it allows for the integration of GraphQL into a http api pipeline via Plug
, and absinthe_plug
requires Absinthe as a dependency. I won’t be able to cover everything. Otherwise this post would have too much information. View the full code to see all the details.
Authentication
As far as I can tell the GraphQL spec doesn’t outline any requirements on how to authenticate a user, which I appreciate. While the GraphQL spec is much more specific then REST, there are certain areas like Authentication where the spec allows for the developer to decide what to implement. In this post I will implement a simple token authentication strategy via HTTP Authentication header. Here’s how I went about adding Authentication:
Add absinthe_plug
to your deps
defp deps do
[
{:absinthe_plug, "~> 1.4"},
{:phoenix, "~> 1.4.0"},
....
]
end
Setup the router to handle api requests and setup the GraphiQL utility.
# lib/blog_web/router.ex
defmodule BlogWeb.Router do
use BlogWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug BlogWeb.Plugs.Context
end
scope "/api" do
pipe_through :api
forward "/graphiql", Absinthe.Plug.GraphiQL,
schema: BlogWeb.Schema,
json_codec: Phoenix.json_library()
forward "/", Absinthe.Plug, schema: BlogWeb.Schema
end
end
Make sure to install the new deps mix deps.get
.
Now a Plug can be created to read the HTTP Authentication header, look up the User, and pass the current_user
to Absinthe.run
via context
in the options
(see docs).
# lib/blog_web/plugs/contex.ex
defmodule BlogWeb.Plugs.Context do
@behaviour Plug
import Plug.Conn
import Ecto.Query, only: [where: 2]
alias Blog.Repo
alias Blog.Accounts.User
def init(opts), do: opts
def call(conn, _) do
context = build_context(conn)
# Absinthe.Plug calls Absinthe.run() with the options added to the `conn`.
Absinthe.Plug.put_options(conn, context: context)
end
@doc """
Return the current user context based on the authorization header
"""
def build_context(conn) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, current_user} <- authorize(token) do
%{current_user: current_user}
else
_ -> %{}
end
end
# Note this is a simple token auth strategy. This is should not be used in production.
defp authorize(token) do
User
|> where(token: ^token)
|> Repo.one()
|> case do
nil -> {:error, "invalid authorization token"}
user -> {:ok, user}
end
end
end
Since Absinthe.run
passes along the context
, the resolvers
can handle an authenticated / unauthenticated request. The schema.ex
file defines the query
for posts
, and defines a resolver
for the request.
# lib/blog_web/schema
query do
@desc "Get all posts"
field :posts, list_of(:post) do
# the third argument passed is the `context` set via `plug`
resolve(&Resolvers.Content.list_posts/3)
end
....
end
# lib/blog_web/resolvers/content.ex
@doc """
Resolve Posts associated to a User, posts independent of a User and Unauthorized Users
"""
def list_posts(%User{} = author, args, %{context: %{current_user: current_user}}) do
{:ok, Content.list_posts_by_author(args, author, current_user)}
end
def list_posts(_parent, args, %{context: %{current_user: current_user}}) do
{:ok, Content.list_posts(args, current_user)}
end
def list_posts(_, _, _) do
{:error, "Access denied"}
end
By matching on the context map with a current_user
key, the authenticated user is accounted for. The last head matches for unauthenticated users.
Now with graphiql the Authorization Header can easily be passed in the api call. Navigate to localhost:4000/api/graphiql
then under the Headers
section click Add
. There you can enter a fake token to correspond to a particular user with a value of Bearer <Some-Token-Matching-A-User>
.
Here’s an example query with a valid Authorization token.
Remove the header and make the same request.
Notice that no posts are returned, and the payload included an errors
JSON object.
Authorization
Authorization is not explicitly covered in the GraphQL spec, it is covered in the Learn section of the guides. The guide strongly advises the developer to put the Authorization logic in the business logic layer. Basically, this means put the logic in a module outside the GraphQL specific schema, content types, resolvers, etc.
In my sample code the User schema has a role
attribute. The code has seeds to create a admin
and a consumer
User. The Post schema has an attribute called private_notes
that only Users with a role of admin
can view.
The hook for authorizing a specific field can be implemented via the post
content type
.
# lib/blog_web/schema/content_types.ex
defmodule BlogWeb.Schema.ContentTypes do
use Absinthe.Schema.Notation
alias BlogWeb.Resolvers
object :post do
field :id, :id
field :title, :string
field :body, :string
field :author, :user
field :published_at, :naive_datetime
field :private_notes, :string do
resolve(fn(post, _, context) ->
Resolvers.Content.authorize_post_field(post, :private_notes, context)
end)
end
end
end
The resolver then delegates to the business layer
defmodule BlogWeb.Resolvers.Content do
alias Blog.Accounts.User
alias Blog.Content
alias Blog.Content.Post
def authorize_post_field(post = %Post{}, field, %{context: %{current_user: current_user}}) do
Content.authorize_post_field(post, field, current_user)
end
def authorize_post_field(_post, _field, _) do
{:error, "Not authorized for field"}
end
.....
end
The Blog.Content
is where the business layer logic processes the authorization logic.
# lib/blog/content.ex
def authorize_post_field(post, :private_notes, _current_user = %User{role: "admin"}) do
{:ok, Map.get(post, :private_notes)}
end
def authorize_post_field(_post, :private_notes, _current_user = %User{}) do
{:error, "Field not authorized"}
end
The first function matches on the admin
User role which then returns a {:ok, <val>}
for admin
users. The second function matches for any other user role which returns a {:error, <message>}
tuple preventing the access of the private_notes
field.
Here’s an example query via GraphiQL.
First set a token that belongs to a Admin User.
Below is an example query requesting private notes.
Notice that private notes are returned in the response.
Now let’s test what happens for a consumer
.
Set a token belonging to a Consumer User.
Below is the request for private_notes
by a consumer
.
The result shows the proper error message that was defined in the business layer module.
Conclusion
Exploring GraphQL and Absinthe provided great insights into the spec, and ways to implement a GraphQL api. I’m very impressed with how both GraphQL and Absinthe define and implement the means to handle Authentication and Authorization in an Elixir application. I can see why so many developers are leveraging GraphQL to tackle large APIs.