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. Tokens can be fully stateless, but are backed by a server-side session.
- Tokens are generated by a factory behaviour with a default implementation using symmetric-key JWT's.
- Token verification is completely customizable.
- 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 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.
- (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
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, "~> 1.1.0-beta"},
# to use the default Charon.TokenFactory.SymmetricJwt
{:jason, "~> 1.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.
# The default implementations of session store and token factory require some config as well.
@my_config Charon.Config.from_enum(
token_issuer: "MyApp",
optional_modules: %{
Charon.TokenFactory.SymmetricJwt => %{get_secret: &MyApp.get_jwt_secret/0},
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
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
. 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 session and prefer fully stateless tokens.
RedisStore session cleanup
The default Charon.SessionStore.RedisStore
has a cleanup/1
function that should run periodically, for example using Quantum.
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
@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 match the ID stored in the session
"""
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
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
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()
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
def logout(conn, _params) do
conn
|> SessionPlugs.delete_session(@config)
|> send_resp(204, "")
end
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 again
# 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).