lib/joken_jwks/http_fetcher.ex

defmodule JokenJwks.HttpFetcher do
  @moduledoc """
  Makes a GET request to an OpenID Connect certificates endpoint.

  This must be a standard JWKS URI as per the specification here:
  https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata

  This uses the `Tesla` library to make it easy to test or change the adapter
  if wanted.

  See our tests for an example of mocking the HTTP fetching.
  """
  alias JokenJwks.Middleware.Telemetry, as: JokenJwksTelemetry
  alias Tesla.Middleware, as: M

  @doc """
  Fetches the JWKS signers from the given url.

  This retries up to 10 times with a fixed delay of 500 ms until the server
  delivers an answer. We only perform a GET request that is idempotent.

  We use `:hackney` as it validates certificates automatically.
  """
  @spec fetch_signers(binary, keyword()) :: {:ok, list} | {:error, atom} | no_return()
  def fetch_signers(url, opts) do
    log_level = opts[:log_level]

    with {:ok, resp} <- Tesla.get(new(opts), url),
         {:status, 200} <- {:status, resp.status},
         {:keys, keys} when not is_nil(keys) <- {:keys, resp.body["keys"]} do
      JokenJwks.log(:debug, log_level, "JWKS fetching: fetched keys -> #{inspect(keys)}")
      {:ok, keys}
    else
      {:status, status} when is_integer(status) and status >= 400 and status < 500 ->
        JokenJwks.log(:debug, log_level, "JWKS fetching: #{status} -> client error")
        {:error, :jwks_client_http_error}

      {:status, status} when is_integer(status) and status >= 500 ->
        JokenJwks.log(:debug, log_level, "JWKS fetching: #{status} -> server error")
        {:error, :jwks_server_http_error}

      {:status, _status} ->
        {:error, :status_not_200}

      {:error, :econnrefused} ->
        JokenJwks.log(:debug, log_level, "JWKS fetching: could not connect (:econnrefused)")
        {:error, :could_not_reach_jwks_url}

      {:keys, nil} ->
        {:error, :no_keys_on_response}

      error ->
        JokenJwks.log(:debug, log_level, "JWKS fetching: unkown error #{inspect(error)}")
        error
    end
  end

  @default_adapter Tesla.Adapter.Hackney

  defp new(opts) do
    adapter =
      Application.get_env(:tesla, __MODULE__)[:adapter] ||
        Application.get_env(:tesla, :adapter, @default_adapter)

    adapter = opts[:http_adapter] || adapter

    middleware = [
      {M.JSON, decode_content_types: ["application/jwk-set+json"]},
      M.Logger,
      {JokenJwksTelemetry, telemetry_prefix: opts[:telemetry_prefix]},
      {M.Retry,
       delay: opts[:http_delay_per_retry] || 500,
       max_retries: opts[:http_max_retries_per_fetch] || 10}
    ]

    Tesla.client(middleware, adapter)
  end
end