defmodule Authex do
@moduledoc """
Defines an auth module.
This module provides a simple set of tools for the authorization and authentication
required by a typical API through use of JSON web tokens. To begin, we will want
to generate a secret from which our tokens will be signed with. There is a convenient
mix task available for this:
mix authex.gen.secret
We should keep this secret as an environment variable.
Next, we will want to create our auth module
defmodule MyApp.Auth do
use Authex
def start_link(opts \\\\ []) do
Authex.start_link(__MODULE__, opts, name: __MODULE__)
end
# Callbacks
@impl Authex
def init(opts) do
# Add any configuration listed in Authex.start_link/3
secret = System.get_env("AUTH_SECRET") || "foobar"
opts = Keyword.put(opts, :secret, secret)
{:ok, opts}
end
@impl Authex
def handle_for_token(%MyApp.User{} = resource, opts) do
{:ok, [sub: resource.id, scopes: resource.scopes], opts}
end
def handle_for_token(_resource, _opts) do
{:error, :bad_resource}
end
@impl Authex
def handle_from_token(token, _opts) do
# You may want to perform a database lookup for your user instead
{:ok, %MyApp.User{id: token.sub, scopes: token.scopes}}
end
end
We must then add the auth module to our supervision tree.
children = [
MyApp.Auth
]
## Tokens
At the heart of Authex is the `Authex.Token` struct. This struct is simply
a wrapper around the typical JWT claims. The only additional item is the
`:scopes` and `:meta` key. There are 3 base actions required for these tokens -
creating, signing, and verification.
#### Creating
We can easily create token structs using the `token/3` function.
Authex.token(MyApp.Auth, sub: 1, scopes: ["admin/read"])
The above would create a token struct for a resource with an id of 1 and with
"admin/read" authorization.
#### Signing
Once we have a token struct, we can sign it using the `sign/3` function to
create a compact token binary. This is what we will use for authentication and
authorization for our API.
token = Authex.token(MyApp.Auth, sub: 1, scopes: ["admin/read"])
Authex.sign(MyApp.Auth, token)
#### Verifying
Once we have a compact token binary, we can verify it and turn it back to an
token struct using the `verify/3` function.
token = Authex.token(MyApp.Auth, sub: 1, scopes: ["admin/read"])
compact_token = Authex.sign(MyApp.Auth, token)
{:ok, token} = Authex.verify(MyApp.Auth, compact_token)
## Callbacks
Typically, we want to be able to create tokens from another source of data.
This could be something like a `User` struct. We also will want to take a token
and turn it back into a `User` struct.
To do this, we must implement callbacks. For our auth module above, we can
convert a user to a token.
token = Authex.for_token(MyApp.Auth, user)
compact_token = Authex.sign(MyApp.Auth, token)
As well as turn a token back into a user.
token = Authex.verify(MyApp.Auth, compact_token)
user = Authex.from_token(MyApp.Auth, token)
## Repositories
Usually, use of JSON web tokens requires some form of persistence to blacklist
tokens through their `:jti` claim.
To do this, we must create a repository. A repository is simply a module that
adopts the `Authex.Repo` behaviour. For more information on creating
repositories, please see the `Authex.Repo` documentation.
Once we have created our blacklist, we define it in our opts when starting our
auth module or in the `c:init/1` callback.
During the verification process used by `verify/3`, any blacklist defined in
our config will be checked against. Please be aware of any performance
penatly that may be incurred through use of database-backed repo's without use
of caching.
## Plugs
Authex provides a number of plugs to handle the typical authentication and
authorization process required by an API using your auth module.
For more information on handling authentication, please see the `Authex.Plug.Authentication`
documentation.
For more information on handling authorization, please see the `Authex.Plug.Authorization`
documentation.
"""
alias Authex.{Repo, Server, Signer, Token, Verifier}
@type alg :: :hs256 | :hs384 | :hs512
@type signer_option :: {:alg, alg()} | {:secret, binary()}
@type signer_options :: [signer_option()]
@type verifier_option ::
{:alg, alg()}
| {:time, integer()}
| {:secret, binary()}
| {:blacklist, Authex.Blacklist.t()}
@type verifier_options :: [verifier_option()]
@type option ::
{:secret, binary()}
| {:blacklist, module() | false}
| {:default_alg, alg()}
| {:default_iss, binary()}
| {:default_aud, binary()}
| {:default_sub, binary() | integer()}
| {:default_jti, mfa() | binary() | false}
| {:unauthorized, module()}
| {:forbidden, module()}
@type options :: [option()]
@type t :: module()
@doc """
A callback executed when the auth process starts.
This should be used to dynamically set any config during runtime - such as the
secret key used to sign tokens with.
Returns `{:ok, opts}` or `:ignore`.
## Example
def init(opts) do
secret = System.get_env("AUTH_SECRET")
opts = Keyword.put(opts, :secret, secret)
{:ok, opts}
end
"""
@callback init(options()) :: {:ok, options()} | :ignore
@callback handle_for_token(resource :: any(), Keyword.t()) ::
{:ok, Authex.Token.claims(), signer_options()} | {:error, any()}
@callback handle_from_token(Authex.Token.t(), Keyword.t()) ::
{:ok, resource :: any()} | {:error, any()}
@doc """
Starts the auth process.
Returns `{:ok, pid}` on success.
Returns `{:error, {:already_started, pid}}` if the auth process is already
started or `{:error, term}` in case anything else goes wrong.
## Options
* `:secret` - The secret used to sign tokens with.
* `:blacklist` - A blacklist repo, or false if disabled - defaults to `false`.
* `:default_alg` - The default algorithm used to sign tokens - defaults to `:hs256`.
* `:default_iss` - The default iss claim used in tokens.
* `:default_aud` - The default aud claim used in tokens.
* `:default_ttl` - The default time to live for tokens in seconds.
* `:default_jti` - The default mfa used to generate the jti claim. Can be `false`
if you do not want to generate one - defaults to `{Authex.UUID, :generate, []}`.
"""
@spec start_link(Authex.t(), options(), GenServer.options()) :: GenServer.on_start()
def start_link(module, opts \\ [], server_opts \\ []) do
Server.start_link(module, opts, server_opts)
end
@doc """
Creates a new token.
A token is a struct that wraps the typical JWT claims but also adds a couple
new fields. Please see the `Authex.Token` documentation for more details.
Returns an `Authex.Token` struct.
## Options
* `:time` - The base time (timestamp format) in which to use.
* `:ttl` - The time-to-live for the token in seconds or `:infinity` if no expiration
is required. The lifetime is based on the time provided via the options,
or the current time if not provided.
## Example
Authex.token(MyAuth, sub: 1, scopes: ["admin/read"])
"""
@spec token(Authex.t(), Authex.Token.claims(), Authex.Token.options()) :: Authex.Token.t()
def token(module, claims \\ [], opts \\ []) do
Token.new(module, claims, opts)
end
@doc """
Signs a token, creating a compact token.
The compact token is a binary that can be used for authentication and authorization
purposes. Typically, this would be placed in an HTTP header, such as:
```bash
Authorization: Bearer mytoken
```
Returns `compact_token` or raises an `Authex.Error`.
## Options
* `:secret` - The secret key to sign the token with.
* `:alg` - The algorithm to sign the token with - defaults to `:hs256`
Any option provided would override the default set in the config.
"""
@spec sign(Authex.t(), Authex.Token.t(), signer_options()) :: binary()
def sign(module, %Authex.Token{} = token, opts \\ []) do
module
|> Signer.new(opts)
|> Signer.compact(token)
end
@doc """
Generates a compact token from a set of claims.
This is simply a shortened version of calling `token/3` and `sign/3`.
## Options
All options are the same available in `token/3` and `sign/3`.
"""
@spec compact_token(Authex.t(), Authex.Token.claims(), signer_options()) :: binary()
def compact_token(module, claims \\ [], opts \\ []) do
token = token(module, claims, opts)
sign(module, token, opts)
end
@doc """
Verifies a compact token.
Verification is a multi-step process that ensures:
1. The token has not been tampered with.
2. The current time is not before the `nbf` value.
3. The current time is not after the `exp` value.
4. The token `jti` is not included in the blacklist (if provided).
If all checks pass, the token is deemed verified.
Returns `{:ok, token}` or `{:error, reason}`.
## Options
* `:time` - The base time (timestamp format) in which to use.
* `:secret` - The secret key to verify the token with.
* `:alg` - The algorithm to verify the token with
* `:blacklist` - The blacklist module to verify with.
Any option provided would override the default set in the config.
## Example
{:ok, token} = Authex.verify(MyAuth, compact_token)
"""
@spec verify(Authex.t(), binary(), verifier_options()) ::
{:ok, Authex.Token.t()}
| {:error,
:bad_token
| :not_ready
| :expired
| :blacklisted
| :blacklist_error
| :jti_unverified}
def verify(module, compact_token, opts \\ []) do
Verifier.run(module, compact_token, opts)
end
@doc """
Refreshes an `Authex.Token` into a new `Authex.Token`.
When using this function, the assumption has already been made that you have
verified it with `verify/3`. This will extract the following claims from the
original token:
* `:sub`
* `:iss`
* `:aud`
* `:scopes`
* `:meta`
It will then take these claims and generate a new token with them.
## Options
Please see the options available at `token/3`.
## Example
token = Authex.refresh(token)
"""
@spec refresh(Authex.t(), Authex.Token.t(), Authex.Token.options()) :: Authex.Token.t()
def refresh(module, token, opts \\ []) do
claims =
token
|> Map.from_struct()
|> Enum.into([])
|> Keyword.take([:sub, :iss, :aud, :scopes, :meta])
token(module, claims, opts)
end
@doc """
Converts an `Authex.Token` into a resource.
This invokes the `c:handle_from_token/2` defined in the auth module. Please see
the callback docs for further details.
Returns `{:ok, resource}` or `{:error, reason}`.
## Options
You can also include any additional options your callback might need.
## Example
{:ok, user} = Authex.from_token(token)
"""
@spec from_token(Authex.t(), Authex.Token.t(), verifier_options() | Keyword.t()) ::
{:ok, any()} | {:error, any()}
def from_token(module, %Token{} = token, opts \\ []) do
module.handle_from_token(token, opts)
end
@doc """
Converts a resource into an `Authex.Token`.
This invokes the `c:handle_for_token/2` callback defined in the auth module. Please
see the callback docs for further details.
Returns `{:ok, token}` or `{:error, reason}`
## Options
Please see the options available in `token/3`. You can also include any
additional options your callback might need.
## Example
{:ok, token} = Authex.for_token(MyAuth, user)
"""
@spec for_token(Authex.t(), resource :: any(), signer_options() | Keyword.t()) ::
{:ok, Authex.Token.t()} | {:error, any()}
def for_token(module, resource, opts \\ []) do
with {:ok, claims, opts} <- module.handle_for_token(resource, opts) do
{:ok, token(module, claims, opts)}
end
end
@doc """
Gets the current resource from a `Plug.Conn`.
The resource will only be accessible if the `conn` has been run through the
`Authex.Plug.Authentication` plug.
Returns `{:ok, resource}` or `:error`.
"""
@spec current_resource(conn :: Plug.Conn.t()) :: {:ok, any()} | :error
def current_resource(_conn = %{private: private}) do
Map.fetch(private, :authex_resource)
end
def current_resource(_) do
:error
end
@doc """
Gets the current scopes from a `Plug.Conn`.
The scopes will only be accessible if the `conn` has been run through the
`Authex.Plug.Authentication` plug.
Returns `{:ok, scopes}` or `:error`.
"""
@spec current_scopes(conn :: Plug.Conn.t()) :: {:ok, [binary()]} | :error
def current_scopes(conn) do
with {:ok, token} <- current_token(conn) do
Map.fetch(token, :scopes)
end
end
@doc """
Gets the current scope from a `Plug.Conn`.
The scope will only be accessible if the `conn` has been run through the
`Authex.Plug.Authorization` plug.
Returns `{:ok, scope}` or `:error`.
"""
@spec current_scope(conn :: Plug.Conn.t()) :: {:ok, [binary()]} | :error
def current_scope(_conn = %{private: private}) do
Map.fetch(private, :authex_scope)
end
def current_scope(_) do
:error
end
@doc """
Gets the current token from a `Plug.Conn`.
The token will only be accessible if the `conn` has been run through the
`Authex.Plug.Authentication` plug.
Returns `{:ok, token}` or `:error`.
"""
@spec current_token(conn :: Plug.Conn.t()) :: {:ok, Authex.Token.t()} | :error
def current_token(_conn = %{private: private}) do
Map.fetch(private, :authex_token)
end
def current_token(_) do
:error
end
@doc """
Checks whether a token jti is blacklisted.
This uses the blaclist repo defined in the auth config. The key is the `:jti`
claim in the token.
Returns a boolean.
## Example
Authex.blacklisted?(MyAuth, token)
"""
@spec blacklisted?(Authex.t(), Authex.Token.t()) :: boolean() | :error
def blacklisted?(module, %Authex.Token{jti: jti}) do
blacklist = config(module, :blacklist, false)
Repo.exists?(blacklist, jti)
end
@doc """
Blacklists a token jti.
This uses the blaclist repo defined in the auth config. The key is the `:jti`
claim in the token.
Returns `:ok` on success, or `:error` on failure.
## Example
Authex.blacklist(MyAuth, token)
"""
@spec blacklist(Authex.t(), Authex.Token.t()) :: :ok | :error
def blacklist(module, %Authex.Token{jti: jti}) do
blacklist = config(module, :blacklist, false)
Repo.insert(blacklist, jti)
end
@doc """
Unblacklists a token jti.
This uses the blaclist repo defined in the auth config. The key is the `:jti`
claim in the token.
Returns `:ok` on success, or `:error` on failure.
## Example
MyApp.Auth.unblacklist(token)
"""
@spec unblacklist(Authex.t(), Authex.Token.t()) :: :ok | :error
def unblacklist(module, %Authex.Token{jti: jti}) do
blacklist = config(module, :blacklist, false)
Repo.delete(blacklist, jti)
end
@doc """
Fetches a config value.
## Example
Authex.config(MyAuth, :secret)
"""
@spec config(Authex.t(), atom(), any()) :: any()
def config(module, key, default \\ nil) do
Server.config(module, key, default)
end
defmacro __using__(_opts) do
quote do
@behaviour Authex
if Module.get_attribute(__MODULE__, :doc) == nil do
@doc """
Returns a specification to start this Authex process under a supervisor.
See `Supervisor`.
"""
end
def child_spec(args) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [args]}
}
end
defoverridable(child_spec: 1)
end
end
end