# Charon
Charon is an extensible auth framework for Elixir. 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)
- [Testing](#testing)
- [CSRF protection](#csrf-protection)
- [Telemetry](#telemetry)
- [Copyright and License](#copyright-and-license)
<!-- /TOC -->
## Features
- Charon concerns itself with the security of tokens in stored in browser clients. To prevent tokens from leaking via XSS (to which LocalStorage is vulnerable), either the full tokens or only their signatures can be sent in cookies. This behaviour can be enforced for browser clients.
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. Please read up on [CSRF protection](#csrf-protection) when using cookies.
- Applications can strike their own "statefulness balance". 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 / Valkey.
- Flexible configuration that does not (have to) depend on the application environment.
- Small number of dependencies.
- Built-in `:telemetry` integration for monitoring session lifecycle events.
## 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.
- [CharonLogin](https://github.com/weareyipyip/charon_login) 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, "~> 4.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.
@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
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:
```elixir
# application.ex
def start(_, ) do
redix_opts = [host: "localhost", port: 6379, password: "supersecret", database: 0]
children = [
...
{Charon.SessionStore.RedisStore, pool_size: 10, redix_opts: redix_opts},
...
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
```
### 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
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
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_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
To aid in testing, some utility functions have been added for your convenience.
```elixir
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
```
### CSRF protection
The need for CSRF protection depends on your use case:
- Only applicable for browser clients
- Only applicable when returning the full tokens in cookies (`Charon.SessionPlugs.upsert_session/3` option `token_transport: :cookie_only`)
**For AJAX/XHR requests**: Because of the same-origin policy (SOP), only JavaScript running in your origin can add custom headers to requests. Your JS client could for example set `X-CSRF-Protect=1`. Server-side checking for the presence of the header is sufficient to prevent CSRF. See [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-custom-request-headers-for-ajaxapi) for details.
**For classic HTML-form based pages**: You need to use proper CSRF protection like `Plug.CSRFProtection`, since forms cannot set custom headers.
**For WebSockets**: Make sure you check the socket origin (Phoenix takes care of this by default).
Charon defaults to using `SameSite=strict` cookies to provide defense-in-depth, but this is not sufficient by itself to prevent all CSRF attacks.
### Telemetry
Charon emits [`:telemetry`](https://hexdocs.pm/telemetry) events for session lifecycle operations, allowing you to monitor and track authentication activity in your application.
The following events are available:
- `[:charon, :session, :create]` - Emitted when a new session is created
- `[:charon, :session, :refresh]` - Emitted when an existing session is refreshed
- `[:charon, :session, :delete]` - Emitted when a session is deleted
- `[:charon, :session, :delete_all]` - Emitted when all sessions of a user are deleted
All events include a `count` measurement (always 1) and metadata such as `session_id`, `user_id`, `session_type`, and `token_transport`.
For more details, see the `Charon.Telemetry` module documentation.
## Copyright and License
Copyright (c) 2025, YipYip B.V.
Charon source code is licensed under the [Apache-2.0 License](./LICENSE.md)