README.md

# 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

<!-- TOC -->

- [Charon](#Charon)
  - [Table of contents](#table-of-contents)
  - [Features](#features)
  - [Child packages](#child-packages)
  - [Documentation](#documentation)
  - [How to use](#how-to-use)
    - [Installation](#installation)
    - [Configuration](#configuration)
    - [Setting up a session store](#setting-up-a-session-store)
    - [Protecting routes](#protecting-routes)
    - [Logging in, logging out and refreshing](#logging-in-logging-out-and-refreshing)

<!-- /TOC -->

## 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](https://medium.com/lightrail/getting-token-authentication-right-in-a-stateless-single-page-application-57d0c6474e3) excellent Medium article by Peter Locke.
- Flexible configuration that does not (have to) depend on the application environment.
- Small number of dependencies.

## Child packages

- [CharonAbsinthe](https://github.com/weareyipyip/charon_absinthe) for using Charon with Absinthe.
- [CharonOauth2](https://github.com/weareyipyip/charon_oauth2) adds Oauth2 authorization server capability.
- (planned) 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 can be found at [https://hexdocs.pm/charon](https://hexdocs.pm/charon).

## How to use

### Installation

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

```elixir
def deps do
  [
    {:charon, "~> 2.1.3"},
    # to use the default Charon.TokenFactory.Jwt
    {:jason, "~> 1.0"}
  ]
end
```

### 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.

```elixir
# Charon itself only requires a token issuer and a base secret getter.
# The default implementation of session store requires some config as well.
@my_config Charon.Config.from_enum(
             token_issuer: "MyApp",
             get_base_secret: &MyApp.get_base_secret/0
             optional_modules: %{
               Charon.SessionStore.RedisStore => %{redix_module: MyApp.Redix}
             }
           )

# 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

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`. A default implementation using Redis is provided by `Charon.SessionStore.RedisStore`, as is a dummy store (`Charon.SessionStore.DummyStore`) in case you don't want to use server-side sessions and prefer fully stateless tokens.

### 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

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:

```elixir
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

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

  plug :get_token_from_auth_header
  plug :get_token_sig_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_refresh_token_fresh/2` docs)
  """
  use Plug.Builder

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

  plug :get_token_from_auth_header
  plug :get_token_sig_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_refresh_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

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

```elixir
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_signature_transport" => signature_transport
      })
      when signature_transport in ~w(bearer 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
      |> Utils.set_user_id(user.id)
      |> Utils.set_token_signature_transport(signature_transport)
      # you can add/override claims in the tokens (be careful!)
      |> SessionPlugs.upsert_session(@config, 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 signature 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).