defmodule Joken.Hooks do
@moduledoc """
Behaviour for defining hooks into Joken's lifecycle.
Hooks are passed to `Joken` functions or added to `Joken.Config` through the
`Joken.Config.add_hook/2` macro. They can change the execution flow of a token configuration.
There are 2 kinds of hooks: before and after.
Both of them are executed in a reduce_while call and so must always return either:
- `{:halt, ...}` -> when you want to abort execution (other hooks won't be called)
- `{:cont, ...}` -> when you want to let other hooks execute
## Before hooks
A before hook receives as the first parameter its options and then a tuple with the input of
the function. For example, the `generate_claims` function receives the token configuration plus a
map of extra claims. Therefore, a `before_generate` hook receives:
- the hook options or `[]` if none are given;
- a tuple with two elements where the first is the token configuration and the second is the extra
claims map;
The return of a before hook is always the input of the next hook. Say you want to add an extra claim
with a hook. You could do so like in this example:
defmodule EnsureExtraClaimHook do
use Joken.Hooks
@impl true
def before_generate(_hook_options, {token_config, extra_claims}) do
{:cont, {token_config, Map.put(extra_claims, "must_exist", true)}}
end
end
You could also halt execution completely on a before hook. Just use the `:halt` return with an error
tuple:
defmodule StopTheWorldHook do
use Joken.Hooks
@impl true
def before_generate(_hook_options, _input) do
{:halt, {:error, :stop_the_world}}
end
end
## After hooks
After hooks work similar then before hooks. The difference is that it takes and returns the result of the
operation. So, instead of receiving 2 arguments it takes three:
- the hook options or `[]` if none are given;
- the result tuple which might be `{:error, reason}` or a tuple with `:ok` and its parameters;
- the input to the function call.
Let's see an example with `after_verify`. The verify function takes as argument the token and a signer. So,
an `after_verify` might look like this:
defmodule CheckVerifyError do
use Joken.Hooks
require Logger
@impl true
def after_verify(_hook_options, result, input) do
case result do
{:error, :invalid_signature} ->
Logger.error("Check signer!!!")
{:halt, result}
{:ok, _claims} ->
{:cont, result, input}
end
end
end
On this example we have conditional logic for different results.
## `Joken.Config`
When you create a module that has `use Joken.Config` it automatically implements
this behaviour with overridable functions. You can simply override a callback
implementation directly and it will be triggered when using any of the generated
functions. Example:
defmodule HookToken do
use Joken.Config
@impl Joken.Hooks
def before_generate(_options, input) do
IO.puts("Before generating claims")
{:cont, input}
end
end
Now if we call `HookToken.generate_claims/1` it will call our callback.
Also in `Joken.Config` there is an imported macro for adding hooks with options. Example:
defmodule ManyHooks do
use Joken.Config
add_hook(JokenJwks, jwks_url: "http://someserver.com/.well-known/certs")
end
For an implementation reference, please see the source code of `Joken.Hooks.RequiredClaims`
"""
alias Joken.Signer
@type halt_tuple :: {:halt, tuple}
@type hook_options :: Keyword.t()
@type generate_input :: {Joken.token_config(), extra :: Joken.claims()}
@type sign_input :: {Joken.claims(), Signer.t()}
@type verify_input :: {Joken.bearer_token(), Signer.t()}
@type validate_input :: {Joken.token_config(), Joken.claims(), context :: map()}
@doc "Called before `Joken.generate_claims/3`"
@callback before_generate(hook_options, generate_input) :: {:cont, generate_input} | halt_tuple
@doc "Called before `Joken.encode_and_sign/3`"
@callback before_sign(hook_options, sign_input) :: {:cont, sign_input} | halt_tuple
@doc "Called before `Joken.verify/3`"
@callback before_verify(hook_options, verify_input) :: {:cont, verify_input} | halt_tuple
@doc "Called before `Joken.validate/4`"
@callback before_validate(hook_options, validate_input) :: {:cont, validate_input} | halt_tuple
@doc "Called after `Joken.generate_claims/3`"
@callback after_generate(hook_options, Joken.generate_result(), generate_input) ::
{:cont, Joken.generate_result(), generate_input} | halt_tuple
@doc "Called after `Joken.encode_and_sign/3`"
@callback after_sign(
hook_options,
{:ok, Joken.bearer_token()} | {:error, Joken.error_reason()},
sign_input
) :: {:cont, Joken.sign_result(), sign_input} | halt_tuple
@doc "Called after `Joken.verify/3`"
@callback after_verify(
hook_options,
Joken.verify_result(),
verify_input
) :: {:cont, Joken.verify_result(), verify_input} | halt_tuple
@doc "Called after `Joken.validate/4`"
@callback after_validate(
hook_options,
Joken.validate_result(),
validate_input
) :: {:cont, Joken.validate_result(), validate_input} | halt_tuple
defmacro __using__(_opts) do
quote do
@behaviour Joken.Hooks
@impl true
def before_generate(_hook_options, input), do: {:cont, input}
@impl true
def before_sign(_hook_options, input), do: {:cont, input}
@impl true
def before_verify(_hook_options, input), do: {:cont, input}
@impl true
def before_validate(_hook_options, input), do: {:cont, input}
@impl true
def after_generate(_hook_options, result, input), do: {:cont, result, input}
@impl true
def after_sign(_hook_options, result, input), do: {:cont, result, input}
@impl true
def after_verify(_hook_options, result, input), do: {:cont, result, input}
@impl true
def after_validate(_hook_options, result, input), do: {:cont, result, input}
defoverridable before_generate: 2,
before_sign: 2,
before_verify: 2,
before_validate: 2,
after_generate: 3,
after_sign: 3,
after_verify: 3,
after_validate: 3
end
end
@before_hooks [:before_generate, :before_sign, :before_verify, :before_validate]
@after_hooks [:after_generate, :after_sign, :after_verify, :after_validate]
def run_before_hook(hooks, hook_function, input) when hook_function in @before_hooks do
hooks
|> Enum.reduce_while(input, fn hook, input ->
{hook, opts} = unwrap_hook(hook)
case apply(hook, hook_function, [opts, input]) do
{:cont, _next_input} = res -> res
{:halt, _reason} = res -> res
_ -> {:halt, {:error, :wrong_hook_return}}
end
end)
|> case do
{:error, _reason} = err -> err
res -> {:ok, res}
end
end
def run_after_hook(hooks, hook_function, result, input) when hook_function in @after_hooks do
hooks
|> Enum.reduce_while({result, input}, fn hook, {result, input} ->
{hook, opts} = unwrap_hook(hook)
case apply(hook, hook_function, [opts, result, input]) do
{:cont, result, next_input} -> {:cont, {result, next_input}}
{:halt, _reason} = res -> res
_ -> {:halt, {:error, :wrong_hook_return}}
end
end)
|> case do
{result, input} when is_tuple(input) -> result
res -> res
end
end
defp unwrap_hook({_hook_module, _opts} = hook), do: hook
defp unwrap_hook(hook) when is_atom(hook), do: {hook, []}
end