# 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.0"},
# 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`. 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.
### 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_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_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).
## Copyright and License
Copyright (c) 2023, YipYip B.V.
Charon source code is licensed under the [Apache-2.0 License](LICENSE.md)