Packages

APIac Elixir plug for mutual TLS authentication (RFC8705)

Current section

Files

Jump to
apiac_auth_mtls lib apiac_auth_mtls.ex
Raw

lib/apiac_auth_mtls.ex

defmodule APIacAuthMTLS do
@behaviour Plug
@behaviour APIac.Authenticator
@moduledoc """
An `APIac.Authenticator` plug implementing **section 2** of
OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
([RFC8705](https://tools.ietf.org/html/rfc8705))
Using this scheme, authentication is performed thanks to 2 elements:
- TLS client certificate authentication
- the `client_id` parameter of the `application/x-www-form-urlencoded` body
TLS client certificate authentication may be performed thanks to two methods:
- authentication with a certificate issued by a Certificate Authority (CA) which is called
[PKI Mutual-TLS Method](https://tools.ietf.org/html/rfc8705#section-2.1).
In this case, one of the following certificate attribute is checked against
this attribute registered for the `client_id`:
- Distinguished name
- SAN DNS
- SAN URI
- SAN IP address
- SAN email
- authentication with a self-signed, self-issued certificate which is called
[Self-Signed Certificate Mutual-TLS Method](https://tools.ietf.org/html/rfc8705#section-2.2).
In this case, the certificate is checked against the **subject public key info**
of the registered certificates of the `client_id`
## Plug options
- `:allowed_methods`: one of `:pki`, `:selfsigned` or `:both`. No default value,
mandatory option
- `:pki_callback`: a
`(String.t -> String.t | {tls_client_auth_subject_value(), String.t()} | nil)`
function that takes the `client_id` as a parameter and returns its DN as a `String.t()` or
`{tls_client_auth_subject_value(), String.t()}` or `nil` if no DN is registered for
that client. When no `t:tls_client_auth_subject_value/0` is specified, defaults to
`:tls_client_auth_subject_dn`
- `:selfsigned_callback`: a `(String.t -> binary() | [binary()] | nil)`
function that takes the `client_id` as a parameter and returns the certificate
or the list of the certificate for `the client_id`, or `nil` if no certificate
is registered for that client. Certificates can be returned in DER-encoded format, or
native OTP certificate structure
- `:cert_data_origin`: origin of the peer cert data. Can be set to:
- `:native`: the peer certificate data is retrieved from the connection. Only works when
this plug is used at the TLS termination endpoint. This is the *default value*
- `{:header_param, "Header-Name"}`: the peer certificate data, and more specifically the
parameter upon which the decision is to be made, is retrieved from an HTTP header. When
using this feature, **make sure** that this header is filtered by a n upstream system
(reverse-proxy...) so that malicious users cannot inject the value themselves. For instance,
the configuration could be set to: `{:header_param, "SSL_CLIENT_DN"}`. If there are several
values for the parameter (for instance several `dNSName`), they must be sent in
separate headers. Not compatible with self-signed certiticate authentication
- `:header_cert`: the whole certificate is forwarded in the `"Client-Cert"` HTTP header
as a Base64 encoded value of the certificate's DER serialization, in conformance with
[Client-Cert HTTP Header: Conveying Client Certificate Information from TLS Terminating Reverse Proxies to Origin Server Applications (draft-bdc-something-something-certificate-01)](https://tools.ietf.org/html/draft-bdc-something-something-certificate-01)
- `{:header_cert, "Header-Name"}`: the whole certificate is forwarded in the
"Header-Name" HTTP header as a Base64 encoded value of the certificate's DER serialization
- `{:header_cert_pem, "Header-Name"}`: the whole certificate is forwarded in the
"Header-Name" as a PEM-encoded string and retrieved by this plug
- `:set_error_response`: function called when authentication failed. Defaults to
`APIacAuthBasic.send_error_response/3`
- `:error_response_verbosity`: one of `:debug`, `:normal` or `:minimal`.
Defaults to `:normal`
## Example
```elixir
plug APIacAuthBasic, allowed_methods: :both,
selfsigned_callback: &selfsigned_certs/1,
pki_callback: &get_dn/1
# further
defp selfsigned_certs(client_id) do
:ets.lookup_element(:clients, :client_id, 5)
end
defp get_dn("client-1") do
"/C=US/ST=ARI/L=Chicago/O=Agora/CN=API access certificate"
end
defp get_dn(_), do: nil
```
## Configuring TLS for `#{inspect(__MODULE__)}` authentication
Plugs can authenticate requests on elements contained in the HTTP request.
Mutual TLS authentication, however, occurs on the TLS layer and the authentication
context is only then passed to the plug (`peer_data`).
Usually, when using TLS, only the server is authenticated by the client. But client
authentication by the server can also be activated on an TLS-enabled server:
in this case, both the server and the clients authenticate to each other.
Client authentication can either be optional or mandatory.
When a TLS-enabled server authenticates a client, it checks the client's certificate
against its list of known certificate authorities (CA). CAs are trusted root certificates.
The list of CAs can be changed through configuration.
Note that by default, the Erlang TLS stack does not accept self-signed certificate.
All TLS options are documented in the
[Erlang SSL module documentation](http://erlang.org/doc/man/ssl.html).
### Enabling TLS client authentication
This table summarizes which options are to be activated *on the server*:
| Use-case | TLS options |
|-------------------------------------|---------------------------------------------------------|
| No client authentication (default) | (no specific option to set) |
| Optional client authentication | -`verify: :verify_peer` |
| Mandatory client authentication | -`verify: :verify_peer`<br>- `fail_if_no_peer_cert: true` |
### Example with `plug_cowboy`
To enable optional TLS client authentication:
```elixir
Plug.Cowboy.https(MyPlug, [],
port: 8443,
keyfile: "priv/ssl/key.pem",
certfile: "priv/ssl/cer.pem",
verify: :verify_peer)
```
To enable mandatory TLS client authentication:
```elixir
Plug.Cowboy.https(MyPlug, [],
port: 8443,
keyfile: "priv/ssl/key.pem",
certfile: "priv/ssl/cer.pem",
verify: :verify_peer,
fail_if_no_peer_cert: true)
```
### Allowing TLS connection of clients with self-signed certificates
By default, Erlang's TLS stack rejects self-signed client certificates. To allow it,
use the `verify_fun` TLS parameter with the following function:
```elixir
defp verify_fun_selfsigned_cert(_, {:bad_cert, :selfsigned_peer}, user_state),
do: {:valid, user_state}
defp verify_fun_selfsigned_cert(_, {:bad_cert, _} = reason, _),
do: {:fail, reason}
defp verify_fun_selfsigned_cert(_, {:extension, _}, user_state),
do: {:unkown, user_state}
defp verify_fun_selfsigned_cert(_, :valid, user_state),
do: {:valid, user_state}
defp verify_fun_selfsigned_cert(_, :valid_peer, user_state),
do: {:valid, user_state}
```
Example with `plug_cowboy`:
```elixir
Plug.Cowboy.https(MyPlug, [],
port: 8443,
keyfile: "priv/ssl/key.pem",
certfile: "priv/ssl/cer.pem",
verify: :verify_peer,
verify_fun: {&verify_fun_selfsigned_cert/3, []})
```
## Security considerations
In addition to the security considerations listed in the RFC, consider that:
- Before TLS1.3, client authentication may leak information
([further information](https://blog.funkthat.com/2018/10/tls-client-authentication-leaks-user.html))
- Any CA can signe for any DN (as for any other certificate attribute). Though this is
a well-known security limitation of the X509 infrastructure, issuing certificate with
rogue DNs may be more difficult to detect (because less monitored)
## Other considerations
When activating TLS client authentication, be aware that some browser user
interfaces may prompt the user, in a unpredictable manner, for certificate
selection. You may want to consider starting a TLS-authentication-enabled
endpoint on another port (i.e. one port for web browsing, another one for
API access).
"""
@type tls_client_auth_subject_value ::
:tls_client_auth_subject_dn
| :tls_client_auth_san_dns
| :tls_client_auth_san_uri
| :tls_client_auth_san_ip
| :tls_client_auth_san_email
@impl Plug
def init(opts) do
if is_nil(opts[:allowed_methods]), do: raise(":allowed_methods mandatory option not set")
case {opts[:allowed_methods], opts[:pki_callback], opts[:selfsigned_callback]} do
{method, pki_callback, _}
when method in [:pki, :both] and not is_function(pki_callback, 1) ->
raise "Missing :pki_callback option"
{method, _, selfsigned_callback}
when method in [:selfsigned, :both] and not is_function(selfsigned_callback, 1) ->
raise "Missing :selfsigned_callback option"
_ ->
:ok
end
opts
|> Enum.into(%{})
|> Map.put_new(:cert_data_origin, :native)
|> Map.put_new(:set_error_response, &APIacAuthMTLS.send_error_response/3)
|> Map.put_new(:error_response_verbosity, :normal)
end
@impl Plug
@spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
def call(conn, %{} = opts) do
if APIac.authenticated?(conn) do
conn
else
do_call(conn, opts)
end
end
def do_call(conn, opts) do
with {:ok, conn, credentials} <- extract_credentials(conn, opts),
{:ok, conn} <- validate_credentials(conn, credentials, opts) do
conn
else
{:error, conn, %APIac.Authenticator.Unauthorized{} = error} ->
opts[:set_error_response].(conn, error, opts)
end
end
@doc """
`APIac.Authenticator` credential extractor callback
The returned credentials is a `{String.t, binary}` tuple where:
- the first parameter is the `client_id`
- the second parameter is the raw DER-encoded certificate
"""
@impl APIac.Authenticator
def extract_credentials(conn, opts) do
with {:ok, conn, client_id} <- get_client_id(conn),
{:ok, creds} <- do_extract_credentials(conn, opts) do
{:ok, conn, {client_id, creds}}
else
{:error, conn, reason} ->
{:error, conn,
%APIac.Authenticator.Unauthorized{authenticator: __MODULE__, reason: reason}}
end
end
defp get_client_id(%Plug.Conn{body_params: %Plug.Conn.Unfetched{}} = conn) do
plug_parser_opts =
Plug.Parsers.init(
parsers: [:urlencoded],
pass: ["application/x-www-form-urlencoded"]
)
conn
|> Plug.Parsers.call(plug_parser_opts)
|> get_client_id()
rescue
UnsupportedMediaTypeError ->
{:error, conn, :unsupported_media_type}
end
defp get_client_id(conn) do
client_id = conn.body_params["client_id"]
cond do
client_id == nil ->
{:error, conn, :credentials_not_found}
OAuth2Utils.valid_client_id_param?(client_id) == false ->
{:error, conn, :invalid_client_id}
true ->
{:ok, conn, client_id}
end
end
defp do_extract_credentials(conn, %{cert_data_origin: :native}) do
case Plug.Conn.get_peer_data(conn)[:ssl_cert] do
nil ->
{:error, conn, :no_client_cert_authentication}
raw_tls_cert ->
{:ok, X509.Certificate.from_der!(raw_tls_cert)}
end
end
defp do_extract_credentials(conn, %{cert_data_origin: {:header_param, header_name}}) do
case Plug.Conn.get_req_header(conn, header_name) do
[] ->
{:error, conn, :no_header_value}
headers ->
{:ok, headers}
end
end
defp do_extract_credentials(conn, %{cert_data_origin: :header_cert}) do
do_extract_credentials(conn, %{cert_data_origin: {:header_cert, "Client-Cert"}})
end
defp do_extract_credentials(conn, %{cert_data_origin: {:header_cert, header_name}}) do
case Plug.Conn.get_req_header(conn, String.downcase(header_name)) do
[] ->
{:error, conn, :no_cert_header_value}
[b64_der_cert] ->
with {:ok, der_cert} <- Base.decode64(b64_der_cert),
{:ok, cert} <- X509.Certificate.from_der(der_cert)
do
{:ok, cert}
else
:error ->
{:error, :invalid_b64_encoding_der_cert}
{:error, _} ->
{:error, :invalid_der_cert}
end
[_ | _] ->
{:error, conn, :multiple_certs_in_header}
end
end
defp do_extract_credentials(conn, %{cert_data_origin: {:header_cert_pem, header_name}}) do
case Plug.Conn.get_req_header(conn, String.downcase(header_name)) do
[] ->
{:error, conn, :no_cert_header_value}
[pem_cert] ->
case X509.Certificate.from_pem(pem_cert) do
{:ok, cert} ->
{:ok, cert}
{:error, _} ->
{:error, :invalid_pem_cert}
end
[_ | _] ->
{:error, conn, :multiple_certs_in_header}
end
end
@doc """
`APIac.Authenticator` credential validator callback
The credentials parameter must be an `%X509.Certificate{}` struct
"""
@impl APIac.Authenticator
def validate_credentials(conn, {client_id, {:OTPCertificate, _, _, _} = cert}, opts) do
# technically a root CA certificate is also self-signed, however it
# 1- is absolutely unlikely it would be used for that
# 2- wouldn't have the same public key info, so couldn't impersonate
# another client
# TODO: confirm these assumptions
if :public_key.pkix_is_self_signed(cert) and
opts[:allowed_methods] in [:selfsigned, :both] do
validate_self_signed_cert(conn, client_id, cert, opts)
else
if opts[:allowed_methods] in [:pki, :both] do
validate_pki_cert(conn, client_id, cert, opts)
else
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :no_method_provided
}}
end
end
end
def validate_credentials(_conn, {_client_id, header_values}, %{allowed_methods: :selfsigned})
when is_list(header_values)
do
raise ~s({:header_param, "Header-Name} is unsupported for self-signed certificates)
end
def validate_credentials(conn, {client_id, header_values}, opts) when is_list(header_values) do
case opts[:pki_callback].(client_id) do
{_tls_client_auth_subject_value_parameter, expected} ->
expected in header_values
expected when is_binary(expected) ->
expected in header_values
nil ->
false
end
|> if do
conn =
conn
|> Plug.Conn.put_private(:apiac_authenticator, __MODULE__)
|> Plug.Conn.put_private(:apiac_client, client_id)
{:ok, conn}
else
{:error, conn,
%APIac.Authenticator.Unauthorized{authenticator: __MODULE__, reason: :pki_no_match}}
end
end
defp validate_self_signed_cert(conn, client_id, cert, opts) do
peer_cert_subject_public_key= X509.Certificate.public_key(cert)
registered_certs =
case opts[:selfsigned_callback].(client_id) do
nil ->
[]
[_ | _] = certs ->
certs
cert ->
[cert]
end
public_key_info_match =
Enum.any?(
registered_certs,
fn registered_cert ->
case registered_cert do
{:OTPCertificate, _, _, _} ->
registered_cert
der_cert when is_binary(der_cert) ->
X509.Certificate.from_der!(der_cert)
end
|> X509.Certificate.public_key() == peer_cert_subject_public_key
end
)
if public_key_info_match do
conn =
conn
|> Plug.Conn.put_private(:apiac_authenticator, __MODULE__)
|> Plug.Conn.put_private(:apiac_client, client_id)
{:ok, conn}
else
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :selfsigned_no_cert_match
}}
end
rescue
_ -> {:error, conn, %APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :unknown
}}
end
defp validate_pki_cert(conn, client_id, cert, opts) do
case opts[:pki_callback].(client_id) do
{tls_client_auth_subject_value_parameter, parameter_value} ->
do_validate_pki_cert(tls_client_auth_subject_value_parameter, cert, parameter_value)
parameter_value when is_binary(parameter_value) ->
do_validate_pki_cert(:tls_client_auth_subject_dn, cert, parameter_value)
_ ->
false
end
|> if do
conn =
conn
|> Plug.Conn.put_private(:apiac_authenticator, __MODULE__)
|> Plug.Conn.put_private(:apiac_client, client_id)
{:ok, conn}
else
{:error, conn,
%APIac.Authenticator.Unauthorized{authenticator: __MODULE__, reason: :pki_no_match}}
end
end
defp do_validate_pki_cert(:tls_client_auth_subject_dn, cert, dn) do
# FIXME: is comparing string serialization of this DNs ok on a security
# point of view? Or shall we compare the raw SDNs?
# See further https://tools.ietf.org/html/rfc5280#section-7.1
cert
|> X509.Certificate.subject()
|> X509.RDNSequence.to_string() == dn
end
defp do_validate_pki_cert(tls_client_auth_subject_value_parameter, cert, param_value) do
san_key =
case tls_client_auth_subject_value_parameter do
:tls_client_auth_san_dns -> :dNSName
:tls_client_auth_san_uri -> :uniformResourceIdentifier
:tls_client_auth_san_ip -> :iPAddress
:tls_client_auth_san_email -> :rfc822Name
end
cert
|> X509.Certificate.extension(:subject_alt_name)
|> case do
nil ->
false
{:Extension, {2, 5, 29, 17}, false, values} ->
values
|> Enum.filter(fn {k, _v} -> k == san_key end)
|> Enum.any?(fn {_k, v} -> param_value == List.to_string(v) end)
end
end
@doc """
Implementation of the `APIac.Authenticator` callback
## Verbosity
The following elements in the HTTP response are set depending on the value
of the `:error_response_verbosity` option:
| Error response verbosity | HTTP Status | Headers | Body |
|:-------------------------:|--------------------|---------|---------------------------------------------------------|
| `:debug` | Unauthorized (401) | | `APIac.Authenticator.Unauthorized` exception's message |
| `:normal` | Unauthorized (401) | | |
| `:minimal` | Unauthorized (401) | | |
"""
@impl APIac.Authenticator
def send_error_response(conn, _error, %{:error_response_verbosity => error_response_verbosity})
when error_response_verbosity in [:normal, :minimal] do
conn
|> Plug.Conn.send_resp(:unauthorized, "")
|> Plug.Conn.halt()
end
def send_error_response(conn, error, %{:error_response_verbosity => :debug}) do
conn
|> Plug.Conn.send_resp(:unauthorized, Exception.message(error))
|> Plug.Conn.halt()
end
@doc """
Saves failure in a `Plug.Conn.t()`'s private field and returns the `conn`
See the `APIac.AuthFailureResponseData` module for more information.
"""
@spec save_authentication_failure_response(Plug.Conn.t(),
%APIac.Authenticator.Unauthorized{},
any()) :: Plug.Conn.t()
def save_authentication_failure_response(conn, error, opts) do
failure_response_data =
%APIac.AuthFailureResponseData{
module: __MODULE__,
reason: error.reason,
www_authenticate_header: nil,
status_code: :unauthorized,
body:
if opts[:error_response_verbosity] in [:normal, :minimal] do
nil
else
Exception.message(error)
end
}
APIac.AuthFailureResponseData.put(conn, failure_response_data)
end
end