JWT (Access and Refresh Token) Authentication in Phoenix/Elixir using Guardian and Guardian DB

In Phoenix, you can build your authentication system by running mix phx.gen.auth command on your terminal. However, when working on a microservice architecture that has a dedicated identity/auth server, this mix task becomes limited and we have to explore other ways. JSON Web Token (JWTs) is one other way that can be used to achieve this requirement. It is an open standard protocol that defines a common way in which two parties, a client and a server can share information. Get more about JWT here

Our use Case

Let’s imagine that we are tasked with creating an Identity Server to be used in a microservice architecture. The identity server will be responsible for creating user accounts and issuing tokens to be used in other services. Users will be required to log in to get an access token. Because of security reasons, the access token will last for 15 minutes and users will be required to log in again. Since this is not a good user experience, we shall also need to generate a refresh token that the client can use to request a new access token. The refresh token will be stored in the database and can be revoked at any moment. The refresh token will last for 1 day. Refresh token should only be used to get access token and therefore, it should only be directed to the identity server. We are going to use Phoenix as our core web framework, Argon2 to hash and verify passwords, Guardian to generate and verify tokens, Guardian DB to keep track of our refresh token in the database, and PostgreSQL. If you want to skip ahead, here is the source code. I will focus more on setting Guardian and Guardian DB in Phoenix since the rest is just Phoenix stuff.

Setup Phoenix

Now, create a new project with mix phx.new identity --no-html --no-assets. Once this is done, please cd to identity, and let’s configure our database next.

Configure PostgreSQL

Usually, I prefer to have the DB connection credentials in an environment file. Please create a .env file at the root of the project and put the following variables.

export DB_HOST=localhost
export IDENTITY_DB_NAME=identity_dev
export IDENTITY_DB_USER=identity_dev
export IDENTITY_DB_PASS=identity_dev

export DB_TEST_HOST=localhost
export IDENTITY_DB_TEST_USER=identity_test
export IDENTITY_DB_TEST_PASS=identity_test

In our config/dev.exs please set the Identity.Repo as follows

# Configure your database
config :identity, Identity.Repo,
  username: System.get_env("IDENTITY_DB_USER", "postgres"),
  password: System.get_env("IDENTITY_DB_PASS", "postgres"),
  hostname: System.get_env("DB_HOST", "localhost"),
  database: System.get_env("IDENTITY_DB_NAME", "identity_dev"),
  stacktrace: true,
  show_sensitive_data_on_connection_error: true,
  pool_size: 10

in our config/test.exs set the Identity.Repo as follows

config :identity, Identity.Repo,
  username: System.get_env("IDENTITY_DB_TEST_USER", "postgres"),
  password: System.get_env("IDENTITY_DB_TEST_PASS", "postgres"),
  hostname: System.get_env("DB_TEST_HOST", "localhost"),
  database: "identity_test#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: System.schedulers_online() * 2

Now, export your environment variables by executing source .env and run database setup with mix ecto.create. Once we have our database setup, lets generate user schema and migration using phoenix generator below

mix phx.gen.json Users User users email:string:unique oid:string:unique password_hash:string firstname:string lastname:string

This command will generate some files. but we are interested in the migration and the User Schema. so lets have a look at those starting with the migration. Please change your code to look like this.

defmodule Identity.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string, null: false
      add :oid, :uuid, null: false
      add :firstname, :string, null: false
      add :lastname, :string, null: false
      add :password_hash, :string, null: false

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:oid])
    create unique_index(:users, [:email])
  end
end

Email and oid fields are unique. we shall use oid (which is a uuid) to identify user publicly but id field will also be generated and can be used internally. Once we are done with our migration, lets us updated our user schema with the following.

defmodule Identity.Users.User do
  use Ecto.Schema
  import Ecto.Changeset

  @mail_regex ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/

  schema "users" do
    field :password_hash, :string
    field :email, :string
    field :oid, Ecto.UUID
    field :firstname, :string
    field :lastname, :string
    field :password, :string, virtual: true

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :oid, :firstname, :lastname, :password])
    |> validate_required([:email, :firstname, :lastname])
    |> maybe_validate_password()
    |> validate_format(:email, @mail_regex, message: "invalid email")
    |> unique_constraint(:oid)
    |> unique_constraint(:email)
    |> put_password_hash()
    |> maybe_put_oid()
    |> put_downcased_email()
  end

  defp maybe_validate_password(changeset) do
    if changeset.data.id do
      changeset
    else
      changeset
      |> validate_required([:password])
      |> validate_length(:password, min: 6)
    end
  end

  defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pass}} = changeset) do
    changeset |> put_change(:password_hash, Argon2.hash_pwd_salt(pass))
  end

  defp put_password_hash(changeset), do: changeset

  defp put_downcased_email(%Ecto.Changeset{valid?: true, changes: %{email: email}} = changeset) do
    changeset |> put_change(:email, email |> String.downcase())
  end

  defp put_downcased_email(changeset), do: changeset

  defp maybe_put_oid(%Ecto.Changeset{valid?: true} = changeset) do
    if changeset.data.id do
      changeset
    else
      case get_field(changeset, :oid) do
        nil -> changeset |> put_change(:oid, Ecto.UUID.generate())
        _ -> changeset
      end
    end
  end

  defp maybe_put_oid(changeset), do: changeset
end

As you can see, we have some utility functions to help us update our schema based on some actions. We are generating the oid and down casing our email. We are also hashing our password using Argon2 library (please see the guide on how to install it in the official docs shared above) and updating our schema with it. Notice that we marked the password field as virtual and this is intentional but it will not be saved to the database. Phoenix also generate for us lib/users.ex module which I am not going to discuss in this article but we shall use in our authentication. Okay, let move on to the fun part of setting Guardian.

Guardian and Guardian DB

Guardian is the token authentication library that is used in Elixir. It provides an interface that you must implement for your specific need. Guardian DB is another library that depends on Guardian for token tracking. We shall focus on the access and refresh token but you are not limited to only these. Okay, let’s start with installation. in the mix.exs file add guardian and guardian db like so and execute mix deps.get on your terminal

  defp deps do
    [
     ...
      {:guardian, "~> 2.3"},
      {:guardian_db, "~> 2.0"}
    ]
  end

Once we have guardian installed, lets create and implementation module like so.

defmodule Identity.Guardian do
  use Guardian, otp_app: :identity
  alias Identity.Users
  alias Identity.Users.User

  def subject_for_token(%User{oid: id}, _claims) do
    {:ok, id}
  end

  def subject_for_token(_, _) do
    {:error, :user_id_not_provided}
  end

  def resource_from_claims(%{"sub" => id}) do
    user = Users.get_user_by!(id, :uuid)
    {:ok, user}
  rescue
    Ecto.NoResultsError -> {:error, :user_not_found}
  end

  def after_encode_and_sign(resource, claims, token, _options) do
    with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do
      {:ok, token}
    end
  end

  def on_verify(claims, token, _options) do
    with {:ok, _} <- Guardian.DB.on_verify(claims, token) do
      {:ok, claims}
    end
  end

  def on_revoke(claims, token, _options) do
    with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do
      {:ok, claims}
    end
  end
end

You must override subject_for_token and resource_from_claim callbacks. in our case, subject_for_token function takes in the user struct and return the oid which will be the value of sub in the token claim. resource_from_claim will take the oid of the user from the claim and return the user struct (basically the authenticated user). The rest of the functions in this module are callbacks that are invoked after some action. for example on_verify is called after token is verified. We are going to use these callbacks to store, verify and revoke our refresh token using Guardian.DB.

After we have created our implementation, its time to configure it. so in the config/config.exs add the following

# setup guardian
config :identity, Identity.Guardian,
  issuer: "identity",
  secret_key: System.get_env("AUTH_SECRET"),
  tokens: [
    access: [
      ttl: {15, :minutes}
    ],
    refresh: [
      ttl: {1, :day}
    ]
  ]

# setup gurdian db
config :guardian, Guardian.DB,
  # Add your repository module
  repo: Identity.Repo,
  # default
  schema_name: "guardian_tokens",
  # store all token types if not set
  token_types: ["refresh"],
  # default: 60 minutes
  sweep_interval: 60

As you can see, we are configuring the guardian implementation that we just created. We are also adding our own configuration for the token expiry. I noticed that the default ttl key only accept one configuration for either access or refresh token but if you know of a better way, please comment below. By the way, the configuration must have the isssuer and the secret_key. Additionally, we are also configuring Guardian.DB and we must specify the Repo, the schema_name and for our case the token type that we want to store which is refresh token. The sweep_interval config will help us sweep away expired tokens in our database. now lets generate migration for the guardian_tokens table which will store our tokens. please execute mix guardian.db.gen.migration. Make sure your migration looks like so

defmodule Identity.Repo.Migrations.CreateGuardianDBTokensTable do
  use Ecto.Migration

  def change do
    create table(:guardian_tokens, primary_key: false) do
      add(:jti, :string, primary_key: true)
      add(:aud, :string, primary_key: true)
      add(:typ, :string)
      add(:iss, :string)
      add(:sub, :string)
      add(:exp, :bigint)
      add(:jwt, :text)
      add(:claims, :map)
      timestamps()
    end
  end
end

Now run mix ecto.migrate to run all our migration thus far. Once everything is setup, let’s create auth.ex file in the lib folder and add the following functions that will help us generate access and refresh token, renew access token and revoke refresh token.

  defp create_access_token(%User{} = user) do
    {:ok, access_token, _claim} =
      Guardian.encode_and_sign(user, %{grant_type: "password", role: "individual.customer"},
        token_type: :access,
        ttl: get_ttl_opt(:access)
      )

    {:ok, access_token}
  end

notice that in this function, we are adding role and grant_type as our claims that we shall use to make sure that users with role individual.customer and grant_type password can only access specific resource. we shall achieve this using controller plugs.

  defp create_refresh_token(%User{} = user) do
    {:ok, refresh_token, _claim} =
      Guardian.encode_and_sign(user, %{}, token_type: :refresh, ttl: get_ttl_opt(:refresh))

    {:ok, refresh_token}
  end
  def revoke_refresh_token(refresh_token) do
    {:ok, _claim} = Guardian.revoke(refresh_token)
    :ok
  end
  def renew_access(refresh_token) do
    with {:ok, _old_stuff, {new_access_token, _new_claims}} <-
           Guardian.exchange(refresh_token, "refresh", "access", ttl: get_ttl_opt(:access)) do
      {:ok, new_access_token}
    end
  end
  defp auth_reply(%User{} = user) do
    with {:ok, access_token} <- create_access_token(user),
         {:ok, refresh_token} <- create_refresh_token(user) do
      {:ok, access_token, refresh_token}
    end
  end
  defp get_ttl_opt(:access) do
    :identity
    |> Application.get_env(Identity.Guardian)
    |> Keyword.get(:tokens)
    |> Keyword.get(:access)
    |> Keyword.get(:ttl)
  end

  defp get_ttl_opt(:refresh) do
    :identity
    |> Application.get_env(Identity.Guardian)
    |> Keyword.get(:tokens)
    |> Keyword.get(:refresh)
    |> Keyword.get(:ttl)
  end

Now lets create our register user function that will be used in our auth controller. Once a user is registered we need to generate access and refresh tokens that can be used to access protected routes. Add the following in the auth.ex file

  def register(params) do
    with {:ok, user} <- Users.create_user(params),
         {:ok, access_token, refresh_token} <- auth_reply(user) do
      {:ok, user, access_token, refresh_token}
    end
  end

Also, create another file called auth_controller.ex in the controllers directory and add the following code

  def register(conn, %{"user" => user_params}) do
    with {:ok, user, access_token, refresh_token} <- Auth.register(user_params) do
      conn
      |> put_status(:created)
      |> render(:auth_user, user: user, access_token: access_token, refresh_token: refresh_token)
    end
  end
Using Guardian in our HTTP Requests

Once we have our controller ready, we need to setup guardian to help us check HTTP requests for the access token. first, lets create two pipelines, one that will be used for non protected routes and the other for protected routes. Please add the following in the pipeline folder under identity_web.

defmodule IdentityWeb.Pipelines.EnsureAuthPipeline do
  use Guardian.Plug.Pipeline,
    otp_app: :identity,
    error_handler: IdentityWeb.Errors.GuardianAuthErrorHandler,
    module: Identity.Guardian

  plug Guardian.Plug.EnsureAuthenticated
  plug Guardian.Plug.LoadResource
end
defmodule IdentityWeb.Pipelines.MaybeAuthPipeline do
  use Guardian.Plug.Pipeline,
    otp_app: :identity,
    error_handler: IdentityWeb.Errors.GuardianAuthErrorHandler,
    module: Identity.Guardian

  # If there is an authorization header, restrict it to an access token and validate it
  plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
  # Load the user if either of the verifications worked
  plug Guardian.Plug.LoadResource, allow_blank: true
end

As you can see, each pipeline implements Guardian.Plug.Pipeline and it requires to be configured with the error_handler and the module. Now, let’s have a look at the error handler in the errors folder under identity_web.

defmodule IdentityWeb.Errors.GuardianAuthErrorHandler do
  import Plug.Conn

  @behaviour Guardian.Plug.ErrorHandler

  @impl Guardian.Plug.ErrorHandler
  def auth_error(conn, {type, _reason}, _opts) do
    body = Jason.encode!(%{error: to_string(type)})

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(401, body)
  end
end

The error handler must implement auth_error function. Here we are just making sure that we return the json encoded data. Now lets use our pipelines in the route file. add the following pipelines.

  pipeline :maybe_auth do
    plug IdentityWeb.Pipelines.MaybeAuthPipeline
  end

  pipeline :ensure_auth do
    plug IdentityWeb.Pipelines.EnsureAuthPipeline
  end

Now, let’s use each of this pipeline in our scopes like so

  scope("/api/auth", IdentityWeb) do
    pipe_through [:api, :maybe_auth]

    post "/login", AuthController, :login
    post "/register", AuthController, :register

    post "/token/renew", AuthController, :refresh_token
    post "/token/revoke", AuthController, :revoke_refresh_token
  end

  scope "/api", IdentityWeb do
    pipe_through [:api, :maybe_auth, :ensure_auth]

    get "/users/profile", UserController, :profile
    put "/users/update", UserController, :update
  end

The login, register, token renewal and revokation is using :mybe_auth pipeline while the user profile and user update routes are using combination of :maybe_auth and :ensure_auth pipeline. Please refer in the source code for the implementation of the rest of the routes.

Next, lets create controller plugs that will ensure our users are authenticated and have the correct privileges to access all the actions in the user_controller.ex. Since these are plugs, please create a folder under indentity_web called plugs and add the following code

defmodule IdentityWeb.CheckGrantTypePlug do
  import Phoenix.Controller
  import Plug.Conn
  import Identity.GuardianHelpers

  def init(default), do: default

  def call(conn, grants) do
    with {:ok, claim} <- get_current_claim(conn) do
      grant = claim["grant_type"]

      if(grant in grants) do
        conn
      else
        conn
        |> put_status(:forbidden)
        |> put_view(json: IdentityWeb.ErrorJSON)
        |> render(:"403",
          error: %{
            status: :forbidden,
            reason: "resource forbidden due to grant policy"
          }
        )
        |> halt()
      end
    end
  end
end

this plug will ensure that our users have the grant type of password, otherwise it will deny access.

defmodule IdentityWeb.CheckRolesPlug do
  import Phoenix.Controller
  import Plug.Conn
  import Identity.GuardianHelpers

  def init(default), do: default

  def call(conn, roles) do
    with {:ok, claim} <- get_current_claim(conn) do
      role = claim["role"]

      if(role in roles) do
        conn
      else
        conn
        |> put_status(:forbidden)
        |> put_view(json: IdentityWeb.ErrorJSON)
        |> render(:"403",
          error: %{
            status: :forbidden,
            reason: "Resource forbidden, not enough roles"
          }
        )
        |> halt()
      end
    end
  end
end

this plug will ensure that our users have the correct role, otherwise it will deny access. You may have noticed that we have guardian helpers function. The logic is as follows.

defmodule Identity.GuardianHelpers do
  alias Identity.Guardian

  def get_current_user(%Plug.Conn{} = conn) do
    case Guardian.Plug.authenticated?(conn) do
      true -> {:ok, Guardian.Plug.current_resource(conn)}
      false -> {:error, :unauthorized}
    end
  end

  def get_current_claim(%Plug.Conn{} = conn) do
    case Guardian.Plug.authenticated?(conn) do
      true -> {:ok, Guardian.Plug.current_claims(conn)}
      false -> {:error, :unauthorized}
    end
  end
end

Now that everything is setup, let’s start our server with mix phx.server and create our user. you can find the postman collection here.

In the source code shared above, I have also written how to test such implementation.

Conclusion

Authentication and Authorization are critical in every system. How you implement it highly depends on the specific requirement of the project. But Guardian library provides the building blocks that you need to implement such service in Elixir.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top