GraphQL with Absinthe on Phoenix - Authentication

Updated at: Jul 24, 2023

In the last article about Mutation, we learned how to create records in an easy way, so now we have searches and insertions but we still do not control how the user access that API. For security reasons or just for control of access we need to implement the API Authentication where only logged users can access it.

Goal

Authenticate the user and restrict the API to only the logged ones.

User

First, let’s create a user table in an Account context:

mix phx.gen.json Accounts User users email:string password_hash:string

Removes some files not used:

rm -rf lib/app_web/controllers
rm -rf test
rm lib/app_web/views/changeset_view.ex
rm lib/app_web/views/user_view.ex

And then run the migration:

mix ecto.migrate

We need a User type:

# lib/app/graphql/types/user.ex

defmodule App.GraphQL.Types.User do
  use Absinthe.Schema.Notation

  object :user do
    field :email, non_null(:string)
  end
end

Import it in the types file:

# lib/app/graphql/types.ex

import_types(Types.User)

Signup

Ok, the user is done, now we can create a signup mutation:

# lib/app/graphql/mutations/session.ex

defmodule App.GraphQL.Mutations.Session do
  use Absinthe.Schema.Notation

  alias App.GraphQL.Resolvers

  object :session_mutations do
    field :signup, :session do
      arg(:email, :string)
      arg(:password, :string)

      resolve(&Resolvers.User.signup/2)
    end
  end
end

This mutation contains a signup field that receives the email and password and returns a session to us. This session is the composition of the user data and a token to access the API:

# lib/app/graphql/types/session.ex

defmodule App.GraphQL.Types.Session do
  use Absinthe.Schema.Notation

  object :session do
    field :token, non_null(:string)

    field :user, non_null(:user)
  end
end

Don’t forget to register the mutation:

# lib/app/graphql/schema.ex

import_fields(:session_mutations)

And then create the resolver:

# lib/app/graphql/resolvers/user.ex

defmodule App.GraphQL.Resolvers.User do
  alias App.Accounts
  alias App.GraphQL
  alias App.AuthToken

  def signup(args, _context) do
    case Accounts.create_user(args) do
      {:ok, %Accounts.User{} = user} ->
        {:ok, %{token: AuthToken.create(user), user: %{email: user.email}}}

      {:error, changeset} ->
        {:error, message: "Signup failed!", details: GraphQL.Errors.extract(changeset)}
    end
  end
end

The method create_user of the Phoenix Context Accounts will create a user using the generated code that we’ve made. We need to hash the given password before saving the record inside the changeset:

# lib/app/accounts/user.ex

def changeset(user, attrs) do
  user
  |> cast(attrs, [:email, :password])
  |> hash_password()
  |> validate_required([:email, :password_hash])
end

After cast the password we call the hash_password to encrypt the password, which will be saved into password_hash field, that’s why we validate the presence of this field. The method is that:

# lib/app/accounts/user.ex

defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
  change(changeset, Argon2.add_hash(password))
end

defp hash_password(Ecto.Changeset = changeset), do: changeset

Here we receive the changeset structure and get the password that has changed, with this password we change the changeset calling the Argon2 module to encrypt the given password. This first method just happens in case the changeset is valid, otherwise, we just return the changeset with no change.

Token

A token is an encoded string containing a serialized data that can be used to identify the user in a session or keep some session data. The most common way to keep this token is using the JWT. Phoenix already has a method to encode and decode the token:

# lib/app/auth_token.ex

defmodule App.AuthToken do
  @salt "any salt"

  def create(user) do
    Phoenix.Token.sign(AppWeb.Endpoint, @salt, user.id)
  end

  def verify(token) do
    Phoenix.Token.verify(MyApp.Endpoint, @salt, token, max_age: 60 * 60 * 24)
  end
end

In this case, we’re creating a token containing the user’s id with a default Salt. When we verify the token it sets a 24hrs to it be expired.

Let’s execute the signup mutation:

mutation {
  signup(email: "[email protected]", password: "password") {
    token
    user {
      email
    }
  }
}
{
  "data": {
    "signup": {
      "token": "SFMyNTY.g2gDYQ1uBgDzrpAEewFiAAFRgA.J4cXwwWS7JYfGg1ehdpr6xoutcRWSYztrahwkzNh2D4",
      "user": {
        "email": "[email protected]"
      }
    }
  }
}

Great, now we have a token to authenticate in the API. Before we continue we need to create the sign-in mutation.

Signin

# lib/app/graphql/mutations/session.ex

field :signin, :session do
  arg(:email, :string)
  arg(:password, :string)

  resolve(&Resolvers.User.signin/2)
end

The resolver:

# lib/app/graphql/resolvers/user.ex

def signin(%{email: email, password: password}, _context) do
  case Accounts.authenticate(email, password) do
    {:ok, %Accounts.User{} = user} ->
      {:ok, %{token: AuthToken.create(user), user: %{email: user.email}}}

    {:error, changeset} ->
      {:error, message: "Signin failed!", details: GraphQL.Errors.extract(changeset)}
  end
end

And the Context method:

def authenticate(email, password) do
  User
  |> Repo.get_by(email: email)
  |> Argon2.check_pass(password)
end

The check_pass gets the property password_hash and compares it to the given password.

You can signin like this:

mutation {
  signin(email: "[email protected]", password: "password") {
    token
    user {
      email
    }
  }
}

User’s book

Let’s make the book belongs to a user:

mix ecto.gen.migration add_user_in_books
code priv/repo/migrations/*_add_user_in_books.exs
defmodule App.Repo.Migrations.AddUserInBooks do
  use Ecto.Migration

  def change do
    alter table(:books) do
      add :user_id, references(:users)
    end
  end
end

Let’s add the user relation in the book model:

# lib/app/documents/book.ex

belongs_to :user, App.Accounts.User

And in the book type:

# lib/app/graphql/types/book.ex

field :user, non_null(:user)

And the relation of books in the user model:

# lib/app/accounts/user.ex

has_many :books, App.Documents.Book

And for the book creation, we’ll receive the user owner of that book in the resolution parameter to pass it to the create_book method:

# lib/app/graphql/resolvers/book.ex

def create_book(args, %{context: %{current_user: current_user}}) do
  args
  |> Map.put(:current_user, current_user)
  |> Documents.create_book()
  |> case do
    {:ok, book} ->
      {:ok, book}

    {:error, changeset} ->
      {:error, message: "Book creation failed!", details: GraphQL.Errors.extract(changeset)}
  end

Now create_book receives a user inside the arguments to be associated to the book:

def create_book(attrs \\ %{}) do
  %Book{}
  |> Book.changeset(attrs)
  |> Ecto.Changeset.cast_assoc(:verses, with: &Verse.changeset/2)
  |> Ecto.Changeset.put_assoc(:user, attrs.current_user)
  |> Repo.insert()
end

Now everything about the user is connected and done.

Set Current User

We have the token in the Headers but we needed to capture it to discover who is the user. For that, we can use a Plug to intercept all API requests and extract the user based on the token:

# lib/app/plugs/set_current_user.ex

defmodule App.Plugs.SetCurrentUser do
  import Plug.Conn

  alias App.AuthToken
  alias App.Accounts

  def init(opts), do: opts

  def call(conn, _opts) do
    case get_user(conn) do
      nil -> conn
      user -> Absinthe.Plug.put_options(conn, context: %{current_user: user})
    end
  end

  # private

  defp get_user(conn) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, user_id} <- AuthToken.verify(token),
         user <- Accounts.get_user!(user_id) do
      user
    else
      _error -> nil
    end
  end
end

This Plug gets the Authorization header, which must be written in lowercase, and discard the "Bearer " part of the value. With the token in hand, we call the verify to extract the hashed value that becomes the user’s id. Then just find that id in the database. If some error raises or we can’t find the user, then nil is returned.

Now let’s active it:

# lib/app_web/router.ex

pipeline :api do
  plug :accepts, ["json"]

  plug AppWeb.Plugs.SetCurrentUser
end

Now all requests passing through the :api pipe will try to get the user via the token.

But since we refactored the code on the first article not using router anymore, this plug goes directly to the Endpoint.ex before the Absinthe plug:

# lib/app_web/endpoint.ex

defmodule AppWeb.Endpoint do
  # ...

  plug AppWeb.Plugs.SetCurrentUser
  plug Absinthe.Plug, schema: App.GraphQL.Schema
end

Absinthe Resolution Parameter

Try to create a book with no Authorization header or with a wrong value and you’ll receive:

no function clause matching in App.GraphQL.Resolvers.Book.create_book/2

It happens because we’re waiting for a user key inside the key context from the parameter resolution, the second one of our resolver:

# lib/app/graphql/resolvers/book.ex

create_book(args, %{context: %{current_user: current_user}})

The SetCurrentUser module was responsible to set it inside the context.

Middleware

Ok, we’re protected, but I don’t want that my application raises an error when the user is not present, I just want to avoid the request. Good, we can do it, we can avoid the resolver execution using a middleware:

# lib/app/graphql/middlewares/authenticator.ex

defmodule App.Middlewares.Authenticator do
  def call(resolution, _opts) do
    case resolution.context do
      %{current_user: _current_user} ->
        resolution

      _context ->
        Absinthe.Resolution.put_result(resolution, {:error, "Sign-in required!"})
    end
  end
end

Similar to a Plug it receives the resolution, the same of the resolver, and extra options that you can pass to the middleware. We check if the context has a current user set, if yes we just return the resolution and continue, otherwise we put an error in the resolution.

Try to create the book again and now an error message is returned:

{
  "data": {
    "createBook": null
  },
  "errors": [
    {
      "locations": [
        {
          "column": 3,
          "line": 2
        }
      ],
      "message": "Sign-in required!",
      "path": [
        "createBook"
      ]
    }
  ]
}

Middleware can be used to avoid some field access too, so you can return just a couple of data. By the way, remember always to put the middleware before the resolver, since it is executed from the top to the bottom.

CORS

For sure you try to access this API from some different place than your own server and when you do it, you’ll receive an error more less like this:

Access to fetch at 'http://localhost:4000/' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

To avoid it when can allow some domains access own server using the CorsPlug. Add it on your Endpoint before the SetCurrentUser, since there is the place where we check the request headers:

# lib/app_web/endpoint.ex

defmodule AppWeb.Endpoint do
  # ...
  plug CORSPlug, origin: ~r/^https?:\/\/localhost:\d{4}$/
  plug AppWeb.Plugs.SetCurrentUser
  plug Absinthe.Plug, schema: App.GraphQL.Schema
end

Here we’re accepting all connections from localhost followed by an port of 4 numeric digits.

Conclusion

Authentication is very necessary to protect your API and you can use middleware to control how this access is made. In the next article, we’ll learn about Subscription.

Repository link: https://github.com/wbotelhos/graphql-with-absinthe-on-phoenix

Any suggestion? Please, send me an email here.