defmodule Joken.Config do
@moduledoc ~S"""
Main entry point for configuring Joken. This module has two approaches:
## Creating a map of `Joken.Claim` s
If you prefer to avoid using macros, you can create your configuration manually. Joken's
configuration is just a map with keys being binaries (the claim name) and the value an
instance of `Joken.Claim`.
### Example
%{"exp" => %Joken.Claim{
generate: fn -> Joken.Config.current_time() + (2 * 60 * 60) end,
validate: fn val, _claims, _context -> val < Joken.Config.current_time() end
}}
Since this is cumbersome and error prone, you can use this module with a more fluent API, see:
- `default_claims/1`
- `add_claim/4`
## Automatically load and generate functions (recommended)
Another approach is to just `use Joken.Config` in a module. This will load a signer configuration
(from config.exs) and a map of `Joken.Claim` s.
### Example
defmodule MyAuth do
use Joken.Config
end
This way, `Joken.Config` will implement some functions for you:
- `generate_claims/1`: generates dynamic claims and adds them to the passed map.
- `encode_and_sign/2`: takes a map of claims, encodes it to JSON and signs it.
- `verify/2`: check for token tampering using a signer.
- `validate/2`: takes a claim map and a configuration to run validations.
- `generate_and_sign/2`: combines generation and signing.
- `verify_and_validate/2`: combines verification and validation.
- `token_config/0`: where you customize token generation and validation.
It will also add `use Joken.Hooks` so you can easily hook into Joken's lifecycle.
## Overriding functions
All callbacks in `Joken.Config` and `Joken.Hooks` are overridable. This can be used for
customizing the token configuration. All that is needed is to override the `token_config/0`
function returning your map of binary keys to `Joken.Claim` structs. Example from the
benchmark suite:
defmodule MyCustomClaimsAuth do
use Joken.Config
@impl true
def token_config do
%{} # empty claim map
|> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe"))
|> add_claim("test", fn -> true end, &(&1 == true))
|> add_claim("age", fn -> 666 end, &(&1 > 18))
|> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1))
end
end
## Customizing default generated claims
The default claims generation is just a bypass call to `default_claims/1`. If one would
like to customize it, then we need only to override the token_config function:
defmodule MyCustomDefaults do
use Joken.Config
def token_config, do: default_claims(default_exp: 60 * 60) # 1 hour
end
### Options
You can pass some options to `use Joken.Config` to ease on your configuration:
- `:default_signer`: a signer configuration key in config.exs (see `Joken.Signer`)
"""
import Joken, only: [current_time: 0]
alias Joken.Signer
@default_generated_claims [:exp, :iat, :nbf, :iss, :aud, :jti]
@doc """
Defines the `t:Joken.token_config/0` used for all the operations in this module.
The default implementation is just a bypass call to `default_claims/1`.
"""
@callback token_config() :: Joken.token_config()
@doc """
Generates a JWT claim set.
Extra claims must be a map with keys as binaries. Ex: %{"sub" => "some@one.com"}
"""
@callback generate_claims(extra :: Joken.claims()) ::
{:ok, Joken.claims()} | {:error, Joken.error_reason()}
@doc """
Encodes the given map of claims to JSON and signs it.
The signer used will be (in order of preference):
1. The one represented by the key passed as second argument. The signer will be
parsed from the configuration.
2. If no argument was passed then we will use the one from the configuration
`:default_signer` passed as argument for the `use Joken.Config` macro.
3. If no key was passed for the use macro then we will use the one configured as
`:default_signer` in the configuration.
"""
@callback encode_and_sign(Joken.claims(), Joken.signer_arg() | nil) ::
{:ok, Joken.bearer_token(), Joken.claims()} | {:error, Joken.error_reason()}
@doc """
Verifies token's signature using a Joken.Signer.
The signer used is (in order of precedence):
1. The signer in the configuration with the given `key`.
2. The `Joken.Signer` instance passed to the method.
3. The signer passed in the `use Joken.Config` through the `default_signer` key.
4. The default signer in configuration (the one with the key `default_signer`).
It returns either:
- `{:ok, claims_map}` where claims_map is the token's claims.
- `{:error, [message: message, claim: key, claim_val: claim_value]}` where message can be used
on the frontend (it does not contain which claim nor which value failed).
"""
@callback verify(Joken.bearer_token(), Joken.signer_arg() | nil) ::
{:ok, Joken.claims()} | {:error, Joken.error_reason()}
@doc """
Runs validations on the already verified token.
"""
@callback validate(Joken.claims(), term) ::
{:ok, Joken.claims()} | {:error, Joken.error_reason()}
defmacro __using__(options) do
quote do
import Joken, only: [current_time: 0]
import Joken.Config
use Joken.Hooks
@behaviour Joken.Config
@hooks [__MODULE__]
@before_compile Joken.Config
@doc false
def __default_signer__ do
key = unquote(options)[:default_signer] || :default_signer
Signer.parse_config(key)
end
@impl Joken.Config
def token_config, do: default_claims()
@impl Joken.Config
def generate_claims(extra_claims \\ %{}),
do: Joken.generate_claims(token_config(), extra_claims, __hooks__())
@impl Joken.Config
def encode_and_sign(claims, signer \\ nil)
def encode_and_sign(claims, nil),
do: Joken.encode_and_sign(claims, __default_signer__(), __hooks__())
def encode_and_sign(claims, signer),
do: Joken.encode_and_sign(claims, signer, __hooks__())
@impl Joken.Config
def verify(bearer_token, key \\ nil)
def verify(bearer_token, nil),
do: Joken.verify(bearer_token, __default_signer__(), __hooks__())
def verify(bearer_token, signer),
do: Joken.verify(bearer_token, signer, __hooks__())
@impl Joken.Config
def validate(claims, context \\ %{}),
do: Joken.validate(token_config(), claims, context, __hooks__())
defoverridable token_config: 0,
generate_claims: 1,
encode_and_sign: 2,
verify: 2,
validate: 2
@doc "Combines `generate_claims/1` and `encode_and_sign/2`"
@spec generate_and_sign(Joken.claims(), Joken.signer_arg()) ::
{:ok, Joken.bearer_token(), Joken.claims()} | {:error, Joken.error_reason()}
def generate_and_sign(extra_claims \\ %{}, key \\ __default_signer__()),
do: Joken.generate_and_sign(token_config(), extra_claims, key, __hooks__())
@doc "Same as `generate_and_sign/2` but raises if error"
@spec generate_and_sign!(Joken.claims(), Joken.signer_arg()) ::
Joken.bearer_token()
def generate_and_sign!(extra_claims \\ %{}, key \\ __default_signer__()),
do: Joken.generate_and_sign!(token_config(), extra_claims, key, __hooks__())
@doc "Combines `verify/2` and `validate/2`"
@spec verify_and_validate(Joken.bearer_token(), Joken.signer_arg(), term) ::
{:ok, Joken.claims()} | {:error, Joken.error_reason()}
def verify_and_validate(bearer_token, key \\ __default_signer__(), context \\ %{}),
do: Joken.verify_and_validate(token_config(), bearer_token, key, context, __hooks__())
@doc "Same as `verify_and_validate/2` but raises if error"
@spec verify_and_validate!(Joken.bearer_token(), Joken.signer_arg(), term) ::
Joken.claims()
def verify_and_validate!(bearer_token, key \\ __default_signer__(), context \\ %{}),
do: Joken.verify_and_validate!(token_config(), bearer_token, key, context, __hooks__())
end
end
defmacro __before_compile__(_env) do
quote do
def __hooks__, do: @hooks
end
end
@doc """
Adds the given hook to the list of hooks passed to all operations in this module.
When using `use Joken.Config` in a module, this already adds the module as a hook.
So, if you want to only override one lifecycle callback, you can simply override it
on the module that uses `Joken.Config`.
"""
defmacro add_hook(hook_module, options \\ []) do
quote do
@hooks [unquote({hook_module, options}) | @hooks]
end
end
@doc """
Initializes a map of `Joken.Claim`s with "exp", "iat", "nbf", "iss", "aud" and "jti".
Default parameters can be customized with options:
- `:skip`: do not include claims in this list. Ex: [:iss, :aud]
- `:default_exp`: changes the default expiration of the token. Default is 2 hours
- `:iss`: changes the issuer claim. Default is "Joken"
- `:aud`: changes the audience claim. Default is "Joken"
"""
@spec default_claims(Keyword.t()) :: Joken.token_config()
# credo:disable-for-next-line
def default_claims(options \\ []) do
skip = options[:skip] || []
default_exp = options[:default_exp] || 2 * 60 * 60
default_iss = options[:iss] || "Joken"
default_aud = options[:aud] || "Joken"
generate_jti = options[:generate_jti] || (&Joken.generate_jti/0)
unless is_integer(default_exp) and is_binary(default_iss) and is_binary(default_aud) and
is_function(generate_jti) and is_list(skip) do
raise Joken.Error, :invalid_default_claims
end
generate_config(skip, default_exp, default_iss, default_aud, generate_jti)
end
defp generate_config(skip, default_exp, default_iss, default_aud, generate_jti) do
gen_exp_func = fn -> current_time() + default_exp end
Enum.reduce(@default_generated_claims, %{}, fn claim, acc ->
if claim in skip do
acc
else
case claim do
:exp ->
add_claim(acc, "exp", gen_exp_func, &(&1 > current_time()))
:iat ->
add_claim(acc, "iat", fn -> current_time() end)
:nbf ->
add_claim(acc, "nbf", fn -> current_time() end, &(current_time() >= &1))
:iss ->
add_claim(acc, "iss", fn -> default_iss end, &(&1 == default_iss))
:aud ->
add_claim(acc, "aud", fn -> default_aud end, &(&1 == default_aud))
:jti ->
add_claim(acc, "jti", generate_jti)
end
end
end)
end
@doc """
Adds a `Joken.Claim` with the given claim key to a map.
This is a convenience builder function. It does exactly what this example does:
iex> config = %{}
iex> generate_fun = fn -> "Hi" end
iex> validate_fun = &(&1 =~ "Hi")
iex> claim = %Joken.Claims{generate: generate_fun, validate: validate_fun}
iex> config = Map.put(config, "claim key", claim)
"""
@spec add_claim(Joken.token_config(), binary, fun | nil, fun | nil, Keyword.t()) ::
Joken.token_config()
def add_claim(config, claim_key, generate_fun \\ nil, validate_fun \\ nil, options \\ [])
def add_claim(config, claim_key, nil, nil, _options)
when is_map(config) and is_binary(claim_key) do
raise Joken.Error, :claim_configuration_not_valid
end
def add_claim(config, claim_key, generate_fun, validate_fun, options)
when is_map(config) and is_binary(claim_key) do
validate_fun = if validate_fun, do: wrap_validate_fun(validate_fun), else: validate_fun
claim = %Joken.Claim{generate: generate_fun, validate: validate_fun, options: options}
Map.put(config, claim_key, claim)
end
# This ensures that all validate functions are called with arity 2 and gives some
# more helpful message in case of errors
defp wrap_validate_fun(fun) do
{:arity, arity} = :erlang.fun_info(fun, :arity)
case arity do
1 ->
fn val, _claims, _ctx -> fun.(val) end
2 ->
fn val, claims, _ctx -> fun.(val, claims) end
3 ->
fun
_ ->
raise Joken.Error, :bad_validate_fun_arity
end
end
end