View Source Charon

Charon is an extensible auth framework for Elixir, mostly for API's. The base package provides token generation & verification, and session handling. Additional functionality can be added with child packages. The package is opinionated with sane config defaults as much as possible.

table-of-contents

Table of contents

features

Features

  • Striking a "statefulness" balance is left to the application. By default, access token are fully stateless, and refresh tokens are backed by a server-side session.
  • A single base secret is used to derive special-purpose keys (like Phoenix's :secret_key_base), simplifying key management
  • Tokens are generated by a factory behaviour with a default implementation using symmetric-key signed JWT's.
  • Default token factory supports key rotation (including new derived keys)
  • Default token factory supports asymmetric signatures using EdDSA with Ed25519 or Ed448 curves.
  • Token verification is completely customizable using simple helper functions.
  • Sessions are managed by a session store behaviour with a default implementation using Redis.
  • To support both web- and other clients, it is possible to "split-off" the token's signatures and put them in cookies (to prevent storing tokens in LocalStorage and other insecure storage). This idea is inspired by this excellent Medium article by Peter Locke.
  • Flexible configuration that does not (have to) depend on the application environment.
  • Small number of dependencies.

child-packages

Child packages

  • CharonAbsinthe for using Charon with Absinthe.
  • CharonOauth2 adds Oauth2 authorization server capability.
  • CharonLogin adds authentication flows/challenges to generically provide login checks like password and MFA.
  • (planned) CharonSocial for social login.
  • (planned) CharonPermissions for authorization checks.

documentation

Documentation

Documentation can be found at https://hexdocs.pm/charon.

how-to-use

How to use

installation

Installation

The package can be installed by adding charon to your list of dependencies in mix.exs:

def deps do
  [
    {:charon, "~> 3.0"}
  ]
end

configuration

Configuration

Configuration has been made easy using a config helper struct Charon.Config, which has a function from_enum/1 that verifies that your config is complete and valid, raising on missing fields. By using multiple config structs, you can support multiple configurations within a single application. The main reason to use multiple sets of configuration is that you can support different auth requirements in this way. You could, for example, create a never-expiring session for ordinary users and create a short-lived session for application admins.

# Charon itself only requires a token issuer and a base secret getter.
@my_config Charon.Config.from_enum(
             token_issuer: "MyApp",
             get_base_secret: &MyApp.get_base_secret/0
           )

# it is possible to use the application environment as well if you wish
@my_config Application.compile_env(:my_app, :charon) |> Charon.Config.from_enum()

setting-up-a-session-store

Setting up a session store

A session store can be created using multiple state stores, be it a database or a GenServer. All you have to do is implement a simple behaviour which you can find in Charon.SessionStore.Behaviour. Two default implementations are provided, Charon.SessionStore.RedisStore uses a Redis database, Charon.SessionStore.LocalStore uses a GenServer. Use Charon.SessionStore.DummyStore in case you don't want to use server-side sessions and prefer fully stateless tokens. The default and recommended option is RedisStore. Use LocalStore for local testing only - it is NOT persistent.

In order to use RedisStore, add Charon.SessionStore.RedisStore to your supervision tree:

# application.ex

def start(_, ) do
  redix_opts = [host: "localhost", port: 6379, password: "supersecret", database: 0]

  children = [
    ...
    {Charon.SessionStore.RedisStore, pool_size: 15, redix_opts: redix_opts},
    ...
  ]

  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  Supervisor.start_link(children, opts)
end

setting-up-a-token-factory

Setting up a token factory

The default token factory is Charon.TokenFactory.Jwt, which requires no configuration by default, and uses symmetric HMAC-SHA256 signatures and a derived key. You can override its settings or set up your own TokenFactory by implementing Charon.TokenFactory.Behaviour.

protecting-routes

Protecting routes

Verifying incoming tokens is supported by the plugs in Charon.TokenPlugs (and submodules). You can use these plugs to create pipelines to verify the tokens and their claims. Note that the plugs don't halt the connection until you call Charon.TokenPlugs.verify_no_auth_error/2, but further processing stops as soon as a previous plug adds an error to the conn. Example access- and refresh token pipelines:

defmodule MyApp.AccessTokenPipeline do
  @moduledoc """
  Verify access tokens. A access token:
   - must have a valid signature
   - must not be expired (and already valid)
   - must have a "type" claim with value "access"
  """
  use Plug.Builder
  import Charon.TokenPlugs

  @config Application.compile_env(:my_app, :charon) |> Charon.Config.from_enum()

  plug :get_token_from_auth_header
  plug :get_token_from_cookie, @config.access_cookie_name
  plug :verify_token_signature, @config
  plug :verify_token_nbf_claim
  plug :verify_token_exp_claim
  plug :verify_token_claim_equals, {"type", "access"}
  plug :verify_no_auth_error, &MyApp.TokenErrorHandler.on_error/2
  plug Charon.TokenPlugs.PutAssigns
end

defmodule MyApp.RefreshTokenPipeline do
  @moduledoc """
  Verify refresh tokens. A refresh token:
   - must have a valid signature
   - must not be expired (and already valid)
   - must have a "type" claim with value "refresh"
   - must have a corresponding session
   - must be fresh (see `Charon.TokenPlugs.verify_token_fresh/2` docs)
  """
  use Plug.Builder
  import Charon.TokenPlugs

  @config Application.compile_env(:my_app, :charon) |> Charon.Config.from_enum()

  plug :get_token_from_auth_header
  plug :get_token_from_cookie, @config.refresh_cookie_name
  plug :verify_token_signature, @config
  plug :verify_token_nbf_claim
  plug :verify_token_exp_claim
  plug :verify_token_claim_equals, {"type", "refresh"}
  plug :load_session, @config
  plug :verify_token_fresh, 10
  plug :verify_no_auth_error, &MyApp.TokenErrorHandler.on_error/2
  plug Charon.TokenPlugs.PutAssigns
end

# use the pipelines in your router
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

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

  pipeline :valid_access_token do
    # or just use the token plugs right here instead of putting them in a separate module
    plug MyApp.AccessTokenPipeline
  end

  pipeline :valid_refresh_token do
    plug MyApp.RefreshTokenPipeline
  end

  scope "/" do
    pipe_through [:api]

    post "/current_session", SessionController, :login
  end

  scope "/" do
    pipe_through [:api ,:valid_access_token]

    delete "/current_session", SessionController, :logout
  end

  scope "/" do
    pipe_through [:api ,:valid_refresh_token]

    # optionally limit the refresh cookie path to this path using `Config.refresh_cookie_opts`
    post "/current_session/refresh", SessionController, :refresh
  end
end

logging-in-logging-out-and-refreshing

Logging in, logging out and refreshing

Create a session controller with login, logout and refresh routes. You can use Charon.SessionPlugs for all operations.

defmodule MyAppWeb.SessionController do
  @moduledoc """
  Controller for a user's session(s), including login, logout and refresh.
  """
  use MyAppWeb, :controller

  alias Charon.{SessionPlugs, Utils}
  alias MyApp.{User, Users}

  @config Application.compile_env(:my_app, :charon) |> Charon.Config.from_enum()

  @doc """
  Login to my app!
  """
  def login(conn, %{
        "email" => email,
        "password" => password,
        "token_transport" => token_transport
      })
      when token_transport in ~w(bearer cookie_only cookie) do
    with {:ok, user} <- Users.get_by(email: email) |> Users.verify_password(password) do
      # you can do extra checks here, like checking if the user is active, for example

      conn
      # you can add/override claims in the tokens (be careful!)
      |> SessionPlugs.upsert_session(
        @config,
        user_id: user.id,
        token_transport: token_transport,
        access_claim_overrides: %{"roles" => user.roles}
      )
      |> put_status(201)
      |> send_token_response(user)
    else
      _error -> send_resp(conn, 401, "user not found or wrong password")
    end
  end

  @doc """
  Logout from my app!
  """
  def logout(conn, _params) do
    conn
    |> SessionPlugs.delete_session(@config)
    |> send_resp(204, "")
  end

  @doc """
  Stay fresh with my app!
  """
  def refresh(%{assigns: %{current_user_id: user_id}} = conn, _params) do
    with %User{status: "active"} = user <- Users.get_by(id: user_id) do
      # here you can do extra checks again

      conn
      # there's no need to set user_id, token transport or extra session payload
      # because these are grabbed from the current session
      # but all added/overridden token claims must be passed in again
      |> SessionPlugs.upsert_session(@config, access_claim_overrides: %{"roles" => user.roles})
      |> send_token_response(user)
    else
      _error -> send_resp(conn, 401, "user not found or inactive")
    end
  end

  ###########
  # Private #
  ###########

  defp send_token_response(conn, user) do
    session = conn |> Utils.get_session() |> Map.from_struct()
    tokens = conn |> Utils.get_tokens() |> Map.from_struct()
    json(conn, %{tokens: tokens, session: session})
  end
end

And that's it :) Optionally, you can add get-all, logout-all and logout-other session endpoints, if your session store supports it (the default Redis one does).

testing

Testing

To aid in testing, some utility functions have been added for your convenience.

defmodule MyAppWeb.ControllerTest do
  use MyAppWeb.ConnCase
  alias Charon.TestHelpers
  alias MyApp.User

  setup seeds do
    # create a user somehow...
    user = %User{id: 1}

    # create a valid session with tokens and put the access token on the conn
    conn = TestHelpers.put_token(conn, user.id, MyAppWeb.Charon.config())

    [conn: conn]
  end

end

Copyright (c) 2023, YipYip B.V.

Charon source code is licensed under the Apache-2.0 License