if Code.ensure_loaded?(Plug.Conn) do
defmodule AppIdentity.Plug do
@moduledoc """
A Plug that verifies App Identity proofs provided via one or more HTTP
headers.
When multiple proof values are provided in the request, all must be
successfully verified. If any of the proof values cannot be verified,
request processing halts with `403 Forbidden`. Should no proof headers are
included, the request is considered invalid.
All of the above behaviours can be modified through
[configuration](`t:option/0`).
The results of completed proof validations can be found at
`%Plug.Conn{private: %{app_identity: %{}}}`, regardless of the success or
failure state.
## Telemetry
When telemetry is enabled, this plug will emit `[:app_identity, :plug,
:start]` and `[:app_identity, :plug, :stop]` events.
"""
alias AppIdentity.App
alias AppIdentity.AppIdentityError
import Plug.Conn
import AppIdentity.Telemetry
@behaviour Plug
@typedoc """
AppIdentity.Plug configuration options prior to validation.
- `apps`: A list of `AppIdentity.App` or `t:AppIdentity.App.input/0` values
to be used for proof validation. Duplicate values will be ignored.
- `disallowed`: A list of algorithm versions that are not allowed when
processing received identity proofs. See `t:AppIdentity.disallowed/0`.
- `finder`: An `t:AppIdentity.App.finder/0` function to load an
`t:AppIdentity.App.input/0` from an external source given a parsed proof.
- `headers`: A list of HTTP header names.
- `on_failure`: The behaviour of the AppIdentity.Plug when proof validation
fails. Must be one of the following values:
- `:forbidden`: Halt request processing and respond with a `403`
(forbidden) status. This is the same as `{:halt, :forbidden}`. This is
the default `on_failure` behaviour.
- `{:halt, Plug.Conn.status()}`: Halt request processing and return the
specified status code. An empty body is emitted.
- `{:halt, Plug.Conn.status(), Plug.Conn.body()}`: Halt request processing
and return the specified status code. The body value is included in the
response.
- `:continue`: Continue processing, ensuring that failure states are
recorded for the application to act on at a later point. This could be
used to implement a distinction between *validating* a proof and
*requiring* that the proof is valid.
- A 1-arity anonymous function or a {module, function} tuple accepting
`Plug.Conn` that returns one of the above values.
At least one of `apps` or `finder` **must** be supplied. If both are
present, apps are looked up in the `apps` list first.
```elixir
plug AppIdentity.Plug, header: "application-identity",
finder: fn proof -> ApplicationModel.get!(proof.id) end
```
"""
@type option ::
AppIdentity.disallowed()
| {:headers, list(binary())}
| {:apps, list(App.input() | App.t())}
| {:finder, App.finder()}
| {:on_failure, on_failure | on_failure_fn}
@type on_failure ::
:forbidden
| :continue
| {:halt, Plug.Conn.status()}
| {:halt, Plug.Conn.status(), Plug.Conn.body()}
@type on_failure_fn :: (Plug.Conn.t() -> on_failure) | {module(), function :: atom()}
@enforce_keys [:headers]
defstruct apps: %{}, disallowed: [], finder: nil, headers: [], on_failure: :forbidden
@typedoc """
Normalized options for AppIdentity.Plug.
"""
@type t :: %__MODULE__{
apps: %{optional(AppIdentity.id()) => App.t()},
disallowed: list(AppIdentity.version()),
finder: nil | App.finder(),
headers: list(binary()),
on_failure: on_failure | {:fn, on_failure}
}
@impl Plug
@spec init(options :: [option()] | t) :: t
def init(%__MODULE__{} = options) do
options
end
def init(options) do
if !Keyword.has_key?(options, :apps) && !Keyword.has_key?(options, :finder) do
raise AppIdentityError, :plug_missing_apps_or_finder
end
apps = get_apps(options)
finder = Keyword.get(options, :finder)
if Enum.empty?(apps) && is_nil(finder) do
raise AppIdentityError, :plug_missing_apps_or_finder
end
%__MODULE__{
apps: apps,
finder: finder,
disallowed: get_disallowed(options),
headers: get_headers(options),
on_failure: get_on_failure(options)
}
end
@impl Plug
@spec call(conn :: Plug.Conn.t(), options :: [option()] | t) :: Plug.Conn.t()
def call(conn, options) when is_list(options) do
call(conn, init(options))
end
def call(conn, %__MODULE__{} = options) do
{metadata, span_context} =
start_span(:plug, %{conn: conn, options: telemetry_options(options)})
conn =
register_before_send(conn, fn conn ->
stop_span(span_context, Map.put(metadata, :conn, conn))
conn
end)
headers =
conn
|> get_request_headers(options)
|> verify_headers(options)
conn = put_private(conn, :app_identity, headers)
if has_errors?(headers) do
dispatch_on_failure(options.on_failure, conn)
else
conn
end
end
defp has_errors?(headers) when is_map(headers) do
Enum.empty?(headers) ||
Enum.any?(headers, fn
{_, nil} -> true
{_, []} -> true
{_, values} -> Enum.any?(values, &(match?(nil, &1) || match?(%{verified: false}, &1)))
end)
end
defp dispatch_on_failure({:fn, {module, function}}, conn) do
dispatch_on_failure(apply(module, function, [conn]), conn)
end
defp dispatch_on_failure({:fn, on_failure}, conn) do
conn
|> on_failure.()
|> dispatch_on_failure(conn)
end
defp dispatch_on_failure(:forbidden, conn) do
dispatch_halt(conn)
end
defp dispatch_on_failure({:halt, status}, conn) do
dispatch_halt(conn, status)
end
defp dispatch_on_failure({:halt, status, body}, conn) do
dispatch_halt(conn, status, body)
end
defp dispatch_on_failure(:continue, conn) do
conn
end
defp get_request_headers(conn, options) do
options.headers
|> Enum.map(&parse_request_header(conn, &1))
|> Enum.reject(&(match?({_, nil}, &1) || match?({_, []}, &1)))
|> Map.new()
end
defp parse_request_header(conn, header) do
{header, get_req_header(conn, header)}
end
defp verify_headers(headers, options) do
Map.new(headers, &verify_header(&1, options))
end
defp verify_header({header, values}, options) do
{header, Enum.reduce_while(values, [], &verify_header_value(&1, &2, options))}
end
defp verify_header_value(value, result, options) do
with {:ok, proof} <- AppIdentity.parse_proof(value),
{:ok, app} <- get_verification_app(proof, options) do
verify_header_proof(proof, app, result, options)
else
_ -> handle_proof_error(options.on_failure, result, nil)
end
end
defp verify_header_proof(proof, app, result, options) do
case AppIdentity.verify_proof(proof, app, disallowed: options.disallowed) do
{:ok, verified_app} when not is_nil(verified_app) -> {:cont, [verified_app | result]}
_ -> handle_proof_error(options.on_failure, result, app)
end
end
defp handle_proof_error(:continue, result, value) do
{:cont, [value | result]}
end
defp handle_proof_error(:forbidden, _values, _value) do
{:halt, nil}
end
defp handle_proof_error({:halt, _}, _values, _value) do
{:halt, nil}
end
defp handle_proof_error({:halt, _, _}, _values, _value) do
{:halt, nil}
end
defp handle_proof_error({:fn, _}, result, value) do
{:cont, [value | result]}
end
defp dispatch_halt(conn, status \\ :forbidden, body \\ []) do
conn
|> put_resp_header("content-type", "text/plain")
|> send_resp(status, body)
|> halt()
end
defp get_verification_app(proof, %{apps: apps, finder: nil}) do
Map.fetch(apps, proof.id)
end
defp get_verification_app(proof, %{apps: apps, finder: finder}) do
case Map.fetch(apps, proof.id) do
:error -> App.new(finder.(proof))
{:ok, _} = ok -> ok
end
end
defp get_apps(options) do
options
|> Keyword.get(:apps, [])
|> Enum.reduce(%{}, fn input, map ->
{id, app} = parse_option_app(input)
Map.put_new(map, id, app)
end)
end
defp get_disallowed(options) do
case Keyword.get(options, :disallowed) do
nil -> []
value when is_list(value) -> value
_ -> raise AppIdentityError, :plug_disallowed_invalid
end
end
defp get_headers(options) do
headers = Keyword.get(options, :headers)
if !is_list(headers) || Enum.empty?(headers) do
raise AppIdentityError, :plug_headers_required
end
Enum.map(headers, &parse_option_header/1)
end
defp get_on_failure(options) do
options
|> Keyword.get(:on_failure)
|> resolve_on_failure_option()
end
defp resolve_on_failure_option(value) when value in [:forbidden, :continue, nil] do
value || :forbidden
end
defp resolve_on_failure_option(value) when is_function(value, 1) do
{:fn, value}
end
defp resolve_on_failure_option({:halt, status} = value)
when is_integer(status) or is_atom(status) do
value
end
defp resolve_on_failure_option({:halt, status, _body} = value)
when is_integer(status) or is_atom(status) do
value
end
defp resolve_on_failure_option({module, function} = value) do
if function_exported?(module, function, 1) do
{:fn, value}
else
raise AppIdentityError, :plug_on_failure_invalid
end
end
defp resolve_on_failure_option(_) do
raise AppIdentityError, :plug_on_failure_invalid
end
defp parse_option_app(input) do
case App.new(input) do
{:ok, app} -> {app.id, app}
{:error, message} -> raise AppIdentityError, message
end
end
defp parse_option_header("") do
raise AppIdentityError, :plug_header_invalid
end
defp parse_option_header(header) do
String.downcase(header)
end
defp telemetry_options(%__MODULE__{} = options) do
apps =
options.apps
|> Map.values()
|> telemetry_apps()
on_failure =
if is_function(options.on_failure, 1) do
"function"
else
options.on_failure
end
[
{:apps, apps},
{:disallowed, options.disallowed},
{:finder, telemetry_app(options.finder)},
{:headers, options.headers},
{:on_failure, on_failure}
]
|> Enum.reject(&match?({_, nil}, &1))
|> Map.new()
end
end
end