lib/tesla/middleware/digest_auth.ex

defmodule Tesla.Middleware.DigestAuth do
  @moduledoc """
  Digest access authentication middleware.

  [Wiki on the topic](https://en.wikipedia.org/wiki/Digest_access_authentication)

  **NOTE**: Currently the implementation is incomplete and works only for MD5 algorithm
  and auth "quality of protection" (qop).

  ## Examples

  ```
  defmodule MyClient do
    use Tesla

    def client(username, password, opts \\ %{}) do
      Tesla.client([
        {Tesla.Middleware.DigestAuth, Map.merge(%{username: username, password: password}, opts)}
      ])
    end
  end
  ```

  ## Options
  - `:username` - username (defaults to `""`)
  - `:password` - password (defaults to `""`)
  - `:cnonce_fn` - custom function generating client nonce (defaults to `&Tesla.Middleware.DigestAuth.cnonce/0`)
  - `:nc` - nonce counter (defaults to `"00000000"`)
  """

  @behaviour Tesla.Middleware

  @impl Tesla.Middleware
  def call(env, next, opts) do
    if env.opts && Keyword.get(env.opts, :digest_auth_handshake) do
      Tesla.run(env, next)
    else
      opts = opts || %{}

      with {:ok, headers} <- authorization_header(env, opts) do
        env
        |> Tesla.put_headers(headers)
        |> Tesla.run(next)
      end
    end
  end

  defp authorization_header(env, opts) do
    with {:ok, vars} <- authorization_vars(env, opts) do
      {:ok,
       vars
       |> calculated_authorization_values
       |> create_header}
    end
  end

  defp authorization_vars(env, opts) do
    with {:ok, unauthorized_response} <-
           env.__module__.request(
             env.__client__,
             method: env.opts[:pre_auth_method] || env.method,
             url: env.url,
             opts: Keyword.put(env.opts || [], :digest_auth_handshake, true)
           ) do
      {:ok,
       %{
         username: opts[:username] || "",
         password: opts[:password] || "",
         path: URI.parse(env.url).path,
         auth:
           Tesla.get_header(unauthorized_response, "www-authenticate")
           |> parse_www_authenticate_header,
         method: env.method |> to_string |> String.upcase(),
         client_nonce: (opts[:cnonce_fn] || (&cnonce/0)).(),
         nc: opts[:nc] || "00000000"
       }}
    end
  end

  defp calculated_authorization_values(%{auth: auth}) when auth == %{}, do: []

  defp calculated_authorization_values(auth_vars) do
    [
      {"username", auth_vars.username},
      {"realm", auth_vars.auth["realm"]},
      {"uri", auth_vars[:path]},
      {"nonce", auth_vars.auth["nonce"]},
      {"nc", auth_vars.nc},
      {"cnonce", auth_vars.client_nonce},
      {"response", response(auth_vars)},
      # hard-coded, will not work for MD5-sess
      {"algorithm", "MD5"},
      # hard-coded, will not work for auth-int or unspecified
      {"qop", "auth"}
    ]
  end

  defp single_header_val({k, v}) when k in ~w(nc qop algorithm), do: "#{k}=#{v}"
  defp single_header_val({k, v}), do: "#{k}=\"#{v}\""

  defp create_header([]), do: []

  defp create_header(calculated_authorization_values) do
    vals =
      calculated_authorization_values
      |> Enum.reduce([], fn val, acc -> [single_header_val(val) | acc] end)
      |> Enum.join(", ")

    [{"authorization", "Digest #{vals}"}]
  end

  defp ha1(%{username: username, auth: %{"realm" => realm}, password: password}) do
    md5("#{username}:#{realm}:#{password}")
  end

  defp ha2(%{method: method, path: path}) do
    md5("#{method}:#{path}")
  end

  defp response(%{auth: %{"nonce" => nonce}, nc: nc, client_nonce: client_nonce} = auth_vars) do
    md5("#{ha1(auth_vars)}:#{nonce}:#{nc}:#{client_nonce}:auth:#{ha2(auth_vars)}")
  end

  defp parse_www_authenticate_header(nil), do: %{}

  defp parse_www_authenticate_header(header) do
    Regex.scan(~r/(\w+?)="(.+?)"/, header)
    |> Enum.reduce(%{}, fn [_, key, val], acc -> Map.merge(acc, %{key => val}) end)
  end

  defp md5(data), do: Base.encode16(:erlang.md5(data), case: :lower)

  defp cnonce, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
end