You’ve assembled your superhero stack. Your React front-end is communicating with an Elixir/Phoenix back-end through an Apollo/Absinthe GraphQL data layer.
You feel invincible.
But that feeling of invincibility quickly turns to panic as your first real development task comes down the pipe. You need to add user authentication to your system.
How do we even do authentication in this stack?
Our application will need to handle both publicly accessible and private queries and mutations through its GraphQL API. How do we set up these queries on the server, and how do we manage users’ sessions on the client?
Great questions! Let the panic pass over you and let’s dive in.
Two Layers of Authentication
Every request made against our Absinthe-based GraphQL server is done through an HTTP request. This request layer provides a fantastic opportunity to lay the groundwork for our authentication system.
Every GraphQL request that’s made against our system will come with an optional auth_token
. A valid auth_token
will map to a single user in our system. This auth_token
is assigned to a user when they sign in.
On each request we’ll look up the user associated with the given auth_token
and attach them to the context of our GraphQL resolvers.
If we can’t find a user associated with a given auth_token
, we’ll return an authorization error (403
) at the HTTP level. Otherwise, if no auth_token
was provided, we simply won’t set the user_id
in our GraphQL context and we’ll move onto processing our query and mutation resolvers.
The key to our authentication (and authorization) system is that the currently signed in user can be pulled from the GraphQL context. This context can be accessed by all of our resolvers and can be used to make decisions about what data to return, which mutations to allow, etc…
Writing Our Context Plug
The first step of building our authentication solution is to write a piece of Plug middleware that populates our GraphQL context with the currently signed in user.
To make things more real, let’s consider the context middleware I’m using for the security-focused SaaS application I’m building (Inject Detect). The middleware is based on the middleware provided by the Absinthe guide.
With that in mind, let’s build out our Plug in a module called InjectDetect.Web.Context
:
defmodule InjectDetect.Web.Context do
@behaviour Plug
import Plug.Conn
def init(opts)
def call(conn, _)
end
To start, we’ll want our plug to implement the Plug
behavior, and to import Plug.Conn
. Implementing the Plug
behavior means that we’ll need to define an init/1
function, and a call/2
function.
The call
function is the entry point into our Plug middleware. Let’s flesh it out a bit:
def call(conn, _) do
case build_context(conn) do
{:ok, context} ->
put_private(conn, :absinthe, %{context: context})
{:error, reason} ->
conn
|> send_resp(403, reason)
|> halt()
_ ->
conn
|> send_resp(400, "Bad Request")
|> halt()
end
end
Here’s the meat of our context middleware. We call out to a function called build_context
which builds our GraphQL context, as the name suggests.
If build_context
returns an :ok
tuple, we stuff the resulting context into our conn
as is expected by Absinthe.
Otherwise, we return either a 403
error or a 400
error in the case of either a bad authentication token, or any other unexpected error.
Now we need to flesh out the build_context
function:
def build_context(conn) do
with ["Bearer " <> auth_token] <- get_req_header(conn, "authorization"),
{:ok, user_id} <- authorize(auth_token)
do
{:ok, %{user_id: user_id}}
else
[] -> {:ok, %{}}
error -> error
end
end
build_context
pulls the auth_token
out of the authorization
header of the request and passes it into an authorize
function. authorize
either returns an :ok
tuple with the current user_id
, or an :error
.
If authorize
returns an error, we’ll pass that back up to our call
function, which returns a 403
for us.
Otherwise, if the authorization
header on the request is empty, we’ll return an empty map in the place of our GraphQL context. This empty context will allow our resolvers to let unauthenticated users access public queries and mutations.
Lastly, let’s take a look at authorize
:
def authorize(auth_token) do
InjectDetect.State.User.find(auth_token: auth_token)
|> case do
nil -> {:error, "Invalid authorization token"}
user -> {:ok, user.id}
end
end
authorize
is a relatively simple function.
It takes in an auth_token
, looks up the user associated with that token, and either returns that user’s id
, or an :error
tuple if no associated user was found.
Armed with our new InjectDetect.Web.Context
Plug, we can build a new :graphql
pipeline in our router:
pipeline :graphql do
plug :fetch_session
plug :fetch_flash
plug InjectDetect.Web.Context
end
And pipe our /graphql
endpoint through it:
scope "/graphql" do
pipe_through :graphql
forward "/", Absinthe.Plug, schema: InjectDetect.Schema
end
Now all GraphQL requests made against our server will run through our authentication middleware, and the currently signed in user will be available to all of our GraphQL resolvers.
Contextual Authentication and Authorization
Now that the currently signed in user can be accessed through our GraphQL context, we can start to perform authentication and authorization checks in our resolvers.
But first, let’s take a look at how we would set up a public query as a point of comparison.
A Public Query
In our application the user
query must be public. It will either return the currently signed in user (if a user is signed in), or nil
if the current user is unauthenticated.
field :user, :user do
resolve &resolve_user/2
end
The user
query takes no parameters, and it directly calls a function called resolve_user
:
def resolve_user(_args, %{context: %{user_id: user_id}}) do
{:ok, User.find(user_id)}
end
def resolve_user(_args, _context), do: {:ok, nil}
We use pattern matching to pull the current user_id
out of our GraphQL context, and then return the user with that user_id
back to our client. If our context is empty, the current user is unauthenticated, so we’ll return nil
back to our client.
Great, that makes sense. The query is returning data to both authenticated and unauthenticated users. It’s completely public and accessible by anyone with access to the GraphQL API.
But what about a private queries?
A Private Query
Similarly, our application has an application
query that returns an object representing a user’s application registered with Inject Detect. This query should only return a specified application if it belongs to the currently signed in user.
field :application, :application do
arg :id, non_null(:string)
resolve &resolve_application/2
end
Once again, our application
query calls out to a resolver function called resolve_application
:
def resolve_application(%{id: id}, %{context: %{user_id: user_id}}) do
case application = Application.find(id) do
%{user_id: ^user_id} -> {:ok, application}
_ -> {:error, %{code: :not_found,
error: "Not found",
message: "Not found"}}
end
end
def resolve_application(_args, _context), do:
{:error, %{code: :not_found,
error: "Not found",
message: "Not found"}}
In this case, we’re once again pattern matching on our GraphQL context to grab the current user_id
. Next, we look up the specified application. If the user_id
set on the application matches the current user’s user_id
, we return the application.
Otherwise, we return a :not_found
error. We’ll also return a :not_found
error if no user_id
is found in our GraphQL context.
By making these checks, an authenticated user can only access their own applications. Anyone else trying to query against their application will receive a :not_found
authorization error.
A Private Mutation with Absinthe Middleware
Let’s take a look at another way of enforcing authentication at the query level.
We have a sign_in
mutation that should only be callable by a signed in user:
field :sign_out, type: :user do
middleware InjectDetect.Middleware.Auth
resolve &handle_sign_out/2
end
You’ll notice that we’ve added a call to an Absinthe middleware
module before the call to our &handle_sign_out/2
resolver. As you might have guessed, the InjectDetect.Middleware.Auth
module is where we’re enforcing an authentication check.
defmodule InjectDetect.Middleware.Auth do
@behavior Absinthe.Middleware
def call(resolution = %{context: %{user_id: _}}, _config) do
resolution
end
def call(resolution, _config) do
resolution
|> Absinthe.Resolution.put_result({:error, %{code: :not_authenticated,
error: "Not authenticated",
message: "Not authenticated"}})
end
end
The call
function is our entry-point into our middleware module. It takes an Absinthe.Resolution
struct as an argument, which contains the current GraphQL context.
If the context contains a user_id
, we know that the user making the request is authorized. We can return the unmodified resolution
from our middleware function, which lets it continue on to the &handle_sign_out/2
resolver function.
Otherwise, if no user_id
is found in the context, we use Absinthe.Resolution.put_result
to modify the resolution
struct before returning it from our middleware. Giving the resolution
a result, in this case a :not_authenticated
:error
tuple, will short circuit the query or mutation’s resolution and immediately return that result to the client.
This piece of middleware effectively prevents unauthenticated users from accessing the sign_out
mutation.
Beautiful.
This middleware pattern is extremely powerful. It can easily be extended to check for specific user roles or other criteria, and can be easily added to an existing query or mutation.
Additionally, multiple middleware modules or functions can be chained together to create a very readable, declarative authentication and authorization scheme around your GraphQL API.
Final Thoughts
At first, all of the moving parts related to handling authentication and authorization in a GraphQL application can be overwhelming.
Thankfully, once you wrap your head around the basic strategies and building blocks involved, the end solution easily falls into place. Authorization and authentication in a GraphQL-based system isn’t much different than in any other system.
Next week, we’ll move on to answering the second set of questions raised in the beginning of this article. How do we manage user sessions on the front-end of our application?
Stay tuned!