Current section
Files
Jump to
Current section
Files
lib/apiac.ex
defmodule APIac do
@moduledoc """
Convenience functions to work with APIac and API requests
"""
@type realm :: String.t()
@type client :: String.t()
@type subject :: String.t()
@type metadata :: %{String.t() => String.t()}
@type http_authn_scheme :: String.t()
@doc """
Returns `true` if the connection is authenticated by an APIac plug, `false` otherwise
"""
def authenticated?(%Plug.Conn{private: %{apiac_authenticator: authenticator}})
when is_atom(authenticator),
do: true
def authenticated?(_), do: false
@doc """
Returns `true` if this is a machine-to-machine authentication (i.e. no *subject* involved), `false` otherwise
"""
@spec machine_to_machine?(Plug.Conn.t()) :: boolean()
def machine_to_machine?(%Plug.Conn{private: %{apiac_client: client}} = conn)
when is_binary(client) do
case conn do
%Plug.Conn{private: %{apiac_subject: subject}} ->
is_nil(subject)
_ ->
true
end
end
def machine_to_machine?(_), do: false
@doc """
Returns `true` if the authentication is on behalf of a real user (the *subject*), `false` otherwise
"""
@spec subject_authenticated?(Plug.Conn.t()) :: boolean()
def subject_authenticated?(%Plug.Conn{private: %{apiac_subject: sub}}) when is_binary(sub),
do: true
def subject_authenticated?(_), do: false
@doc """
Returns the `APIac.Authenticator` that has authenticated the connection, `nil` if none has
"""
@spec authenticator(Plug.Conn.t()) :: atom() | nil
def authenticator(%Plug.Conn{private: %{apiac_authenticator: authenticator}}),
do: authenticator
def authenticator(_), do: nil
@doc """
Returns the name of the client, or `nil` if the connection is unauthenticated
"""
@spec client(Plug.Conn.t()) :: client | nil
def client(%Plug.Conn{private: %{apiac_client: client}}), do: client
def client(_), do: nil
@doc """
Returns the name of the subject, or `nil` if it was not set (unauthenticated connection, machine-tomachine authentication...)
"""
@spec subject(Plug.Conn.t()) :: subject | nil
def subject(%Plug.Conn{private: %{apiac_subject: subject}}), do: subject
def subject(_), do: nil
@doc """
Returns metadata associated with the authenticated connection, or `nil` if there's none
"""
@spec metadata(Plug.Conn.t()) :: %{String.t() => String.t()} | nil
def metadata(%Plug.Conn{private: %{apiac_metadata: metadata}}), do: metadata
def metadata(_), do: nil
@doc """
Sets the HTTP WWW-Authenticate header of a `Plug.Conn` and returns it.
Note that the parameters are passed as a map whose:
- keys are rfc7230 tokens
- values are rfc7230 quoted-strings, but **without the enclosing quotes**: they are mandatory and therefore added automatically
## Examples
```elixir
iex> conn(:get, "/ressource") |>
...> Plug.Conn.put_status(:unauthorized) |>
...> APIac.set_WWWauthenticate_challenge("Basic", %{"realm" => "realm_1"}) |>
...> APIac.set_WWWauthenticate_challenge("Bearer", %{"realm" => "realm_1", "error" => "insufficient_scope", "scope" => "group:read group:write"})
%Plug.Conn{
adapter: {Plug.Adapters.Test.Conn, :...},
assigns: %{},
before_send: [],
body_params: %Plug.Conn.Unfetched{aspect: :body_params},
cookies: %Plug.Conn.Unfetched{aspect: :cookies},
halted: false,
host: "www.example.com",
method: "GET",
owner: #PID<0.202.0>,
params: %Plug.Conn.Unfetched{aspect: :params},
path_info: ["ressource"],
path_params: %{},
peer: {{127, 0, 0, 1}, 111317},
port: 80,
private: %{},
query_params: %Plug.Conn.Unfetched{aspect: :query_params},
query_string: "",
remote_ip: {127, 0, 0, 1},
req_cookies: %Plug.Conn.Unfetched{aspect: :cookies},
req_headers: [],
request_path: "/ressource",
resp_body: nil,
resp_cookies: %{},
resp_headers: [
{"cache-control", "max-age=0, private, must-revalidate"},
{"www-authenticate",
"Basic realm=\"realm_1\", Bearer error=\"insufficient_scope\", realm=\"realm_1\", scope=\"group:read group:write\""}
],
scheme: :http,
script_name: [],
secret_key_base: nil,
state: :unset,
status: 401
}
```
"""
@spec set_WWWauthenticate_challenge(Plug.Conn.t(), http_authn_scheme, %{
String.t() => String.t()
}) :: Plug.Conn.t()
def set_WWWauthenticate_challenge(conn, scheme, params) do
if not rfc7230_token?(scheme), do: raise("Invalid scheme value as per RFC7230")
header_val =
scheme <>
" " <>
Enum.join(
Enum.map(
params,
fn {k, v} ->
# params come non-quoted - let's quote it now
v = "\"#{v}\""
if not rfc7230_token?(k), do: raise("Invalid auth param value")
# https://tools.ietf.org/html/rfc7235#section-2.2
#
# For historical reasons, a sender MUST only generate the quoted-string
# syntax. Recipients might have to support both token and
# quoted-string syntax for maximum interoperability with existing
# clients that have been accepting both notations for a long time.
if not rfc7230_quotedstring?(v), do: raise("Invalid auth param value")
k <> "=" <> v
end
),
", "
)
case Plug.Conn.get_resp_header(conn, "www-authenticate") do
[] ->
Plug.Conn.put_resp_header(conn, "www-authenticate", header_val)
[existing_header_val | _] ->
Plug.Conn.put_resp_header(
conn,
"www-authenticate",
existing_header_val <> ", " <> header_val
)
end
end
@doc """
Returns `true` if the input string is an [rfc7230 quoted-string](https://tools.ietf.org/html/rfc7230#section-3.2.6), `false` otherwise
"""
@spec rfc7230_quotedstring?(String.t()) :: boolean()
def rfc7230_quotedstring?(val) do
Regex.run(
~r{^"([\v\s\x21\x23-\x5B\x5D-\x7E\x80-\xFF]|\\\v|\\\s|\\[\x21-\x7E]|\\[\x80-\xFF])*"$},
val
) != nil
end
@doc """
Returns `true` if the input string is an rfc7230 token, `false` otherwise
"""
@spec rfc7230_token?(String.t()) :: boolean()
def rfc7230_token?(val) do
Regex.run(~r{^[!#$%&'*+\-.\^_`|~0-9A-Za-z]+$}, val) != nil
end
@doc """
Returns `true` if the input string is an [rfc7235 token68](https://tools.ietf.org/html/rfc7235#section-2.1), `false` otherwise
"""
@spec rfc7235_token68?(String.t()) :: boolean()
def rfc7235_token68?(val) do
Regex.run(~r{^[0-9A-Za-z\-._~+/]+=*$}, val) != nil
end
@doc """
Returns the following error response verbosity level depending on the environment:
- dev: `:debug`
- test: `:normal`
- prod: `:normal`
It uses the APIac configuration key `:env` that is by defaults executed to `Mix.env()`
"""
@spec default_error_response_verbosity(Plug.Conn.t()) :: :debug | :normal | :minimal
def default_error_response_verbosity(_conn) do
case Application.get_env(:apiac, :env) do
:dev ->
:debug
:test ->
:normal
:prod ->
:normal
end
end
defmodule AuthFailureResponseData do
@enforce_keys [:module, :reason]
defstruct [:module, :reason, :www_authenticate_header, :status_code, :body]
@type t :: %__MODULE__{
module: module(),
reason: atom(),
www_authenticate_header: {String.t(), map()} | nil,
status_code: Plug.Conn.status() | nil,
body: String.t() | nil
}
@doc """
Returns the authentication failure information from the `Plug.Conn.t()`
"""
@spec get(Plug.Conn.t()) :: [t()] | nil
def get(conn) do
conn.private[:apiac_failed_auth_response_data]
end
@doc """
Adds authentication failure information to the `Plug.Conn.t()` object
"""
@spec put(Plug.Conn.t(), t()) :: Plug.Conn.t()
def put(
%Plug.Conn{private: %{apiac_failed_auth_response_data: data}} = conn,
failure_data) when is_list(data) do
Plug.Conn.put_private(conn, :apiac_failed_auth_response_data, [failure_data | data])
end
def put(conn, failure_data) do
Plug.Conn.put_private(conn, :apiac_failed_auth_response_data, [failure_data])
end
end
end