lib/kitten_blue/jwk.ex

defmodule KittenBlue.JWK do
  @moduledoc """
  Structure containing `kid`, `alg`, `JOSE.JWK` and handling functions
  """

  require Logger

  defstruct [
    :kid,
    :alg,
    :key,
    # optional
    :x509
  ]

  @type t :: %__MODULE__{
          kid: String.t(),
          alg: String.t(),
          key: JOSE.JWK.t(),
          x509: KittenBlue.JWK.X509.t()
        }

  # Set the default value here to avoid compilation errors where Configuration does not exist.
  @http_client Application.compile_env(:kitten_blue, __MODULE__,
                 http_client: Scratcher.HttpClient
               )
               |> Keyword.fetch!(:http_client)

  # NOTE: from_compact/to_conpact does not support Poly1305
  @algs_for_oct ["HS256", "HS384", "HS512"]
  @algs_for_pem [
    "ES256",
    "ES384",
    "ES512",
    "Ed25519",
    "Ed25519ph",
    "Ed448",
    "Ed448ph",
    "PS256",
    "PS384",
    "PS512",
    "RS256",
    "RS384",
    "RS512"
  ]

  @doc """
  ```Elixir
  kid = "sample_201804"
  alg = "RS256"
  key = JOSE.JWK.from_pem_file("rsa-2048.pem")

  kb_jwk = KittenBlue.JWK.new([kid, alg, key])
  kb_jwk = KittenBlue.JWK.new([kid: kid, alg: alg, key: key])
  kb_jwk = KittenBlue.JWK.new(%{kid: kid, alg: alg, key: key})
  ```
  """
  @spec new(params :: Keywords.t()) :: t
  def new(params = [kid: _, alg: _, key: _]) do
    struct(__MODULE__, Map.new(params))
  end

  @spec new(params :: List.t()) :: t
  def new([kid, alg, key]) do
    struct(__MODULE__, %{kid: kid, alg: alg, key: key})
  end

  @spec new(params :: Map.t()) :: t
  def new(params = %{kid: _, alg: _, key: _}) do
    struct(__MODULE__, params)
  end

  @doc """
  Convert `KittenBlue.JWK` list to `JSON Web Key Sets` format public keys.

  ```Elixir
  kb_jwk_list = [kb_jwk]
  public_jwk_sets = KittenBlue.JWK.list_to_public_jwk_sets(kb_jwk_list)
  ```
  """
  @spec list_to_public_jwk_sets(jwk_list :: List.t()) :: map | nil
  def list_to_public_jwk_sets([]) do
    nil
  end

  def list_to_public_jwk_sets(jwk_list) when is_list(jwk_list) do
    %{
      "keys" =>
        jwk_list
        |> Enum.map(fn jwk -> to_public_jwk_set(jwk) end)
        |> Enum.filter(&(!is_nil(&1)))
    }
  end

  @doc """
  Convert `KittenBlue.JWK` to `JSON Web Key Sets` format public key.

  ```Elixir
  public_jwk_set = KittenBlue.JWK.to_public_jwk_set(kb_jwk)
  ```
  """
  @spec to_public_jwk_set(jwk :: t) :: map | nil
  def to_public_jwk_set(jwk = %__MODULE__{}) do
    jwk.key
    |> JOSE.JWK.to_public()
    |> JOSE.JWK.to_map()
    |> elem(1)
    |> Map.put("alg", jwk.alg)
    |> Map.put("kid", jwk.kid)
  end

  def to_public_jwk_set(_) do
    nil
  end

  @doc """
  Convert `JSON Web Key Sets` format public keys to `KittenBlue.JWK` list.

  ```
  kb_jwk_list = KittenBlue.JWK.public_jwk_sets_to_list(public_jwk_sets)
  ```
  """
  @spec public_jwk_sets_to_list(public_json_web_key_sets :: map) :: List.t()
  def public_jwk_sets_to_list(_public_json_web_key_sets = %{"keys" => public_jwk_sets})
      when is_list(public_jwk_sets) do
    public_jwk_sets
    |> Enum.map(fn public_jwk_set -> from_public_jwk_set(public_jwk_set) end)
    |> Enum.filter(&(!is_nil(&1)))
  end

  def public_jwk_sets_to_list(_public_json_web_key_sets) do
    []
  end

  @doc """
  Convert `JSON Web Key Sets` format public key to `KittenBlue.JWK`.

  ```
  kb_jwk = KittenBlue.JWK.from_public_jwk_set(public_jwk_set)
  ```
  """
  @spec from_public_jwk_set(public_json_web_key_set :: map) :: t | nil
  def from_public_jwk_set(jwk_map) when is_map(jwk_map) do
    try do
      with alg when alg != nil <- jwk_map["alg"],
           kid when kid != nil <- jwk_map["kid"],
           key = %JOSE.JWK{} <- jwk_map |> JOSE.JWK.from_map() do
        new(kid: kid, alg: alg, key: key)
      else
        _ -> nil
      end
    rescue
      _ -> nil
    end
  end

  def from_public_jwk_set(_) do
    nil
  end

  @doc """
  Convert `KittenBlue.JWK` List to compact storable format for configration.

  ```
  kb_jwk_list = [kb_jwk]
  kb_jwk_list_config = KittenBlue.JWK.list_to_compact(kb_jwk_list)
  ```
  """
  @spec list_to_compact(jwk_list :: List.t(), opts :: Keyword.t()) :: List.t()
  def list_to_compact(jwk_list, opts \\ []) do
    jwk_list
    |> Enum.map(fn jwk -> to_compact(jwk, opts) end)
  end

  @doc """
  Convert `KittenBlue.JWK` to compact storable format for configration.

  ```
  kb_jwk_config = KittenBlue.JWK.to_compact(kb_jwk)
  ```
  """
  @spec to_compact(jwk :: t(), opts :: Keyword.t()) :: List.t()
  def to_compact(jwk, opts \\ []) do
    case {jwk.alg, opts[:use_map]} do
      {_, true} ->
        [jwk.kid, jwk.alg, jwk.key |> JOSE.JWK.to_map() |> elem(1)]

      {alg, nil} when alg in @algs_for_oct ->
        [
          jwk.kid,
          jwk.alg,
          jwk.key |> JOSE.JWK.to_oct() |> elem(1) |> Base.encode64(padding: false)
        ]

      {alg, nil} when alg in @algs_for_pem ->
        [jwk.kid, jwk.alg, jwk.key |> JOSE.JWK.to_pem() |> elem(1)]

      {_, _} ->
        []
    end
  end

  @doc """
  Convert compact storable format to `KittenBlue.JWK`.

  ```
  kb_jwk_list = KittenBlue.JWK.compact_to_list(kb_jwk_list_config)
  ```
  """
  @spec compact_to_list(jwk_compact_list :: list()) :: t()
  def compact_to_list(jwk_compact_list) when is_list(jwk_compact_list) do
    jwk_compact_list
    |> Enum.map(fn jwk_compact -> from_compact(jwk_compact) end)
    |> Enum.filter(&(!is_nil(&1)))
  end

  @doc """
  Convert compact storable format to `KittenBlue.JWK`.

  ```
  kb_jwk = KittenBlue.JWK.from_compact(kb_jwk_config)
  ```
  """
  @spec from_compact(jwk_compact :: list()) :: t() | nil
  def from_compact(_jwk_compact = [kid, alg, key]) do
    cond do
      is_map(key) ->
        [kid, alg, key |> JOSE.JWK.from_map()] |> new()

      alg in @algs_for_oct ->
        [kid, alg, key |> Base.decode64!(padding: false) |> JOSE.JWK.from_oct()] |> new()

      alg in @algs_for_pem ->
        [kid, alg, key |> JOSE.JWK.from_pem()] |> new()

      true ->
        nil
    end
  end

  @doc """
  Fetch jwks uri and return jwk list.

  ```
  kb_jwk_list = KittenBlue.JWK.fetch!(jwks_uri)
  ```

  NOTE: The HTTP client must be implemented using Scratcher.HttpClient as the Behavior.

  * [hexdocs](https://hexdocs.pm/scratcher/Scratcher.HttpClient.html)
  * [github](https://github.com/ritou/elixir-scratcher/blob/master/lib/scratcher/http_client.ex)
  """
  @spec fetch!(jwks_uri :: String.t()) :: [t()] | nil
  def fetch!(jwks_uri) do
    case @http_client.request(:get, jwks_uri, "", [], []) do
      {:ok, %{status_code: 200, body: body}} ->
        Jason.decode!(body) |> __MODULE__.public_jwk_sets_to_list()

      {:ok, %{} = res} ->
        Logger.warning("HTTP Client returned {:ok, #{inspect(res)}}")
        nil

      {:error, %{reason: _} = error} ->
        Logger.warning("HTTP Client returned {:error, #{inspect(error)}}")
        nil
    end
  end

  @doc """
  Convert config format to `KittenBlue.JWK` for main issuerance.

  For JWT (JWS) signatures, there are cases where a single key is used to issue a signature and multiple keys are used for verification.
  You can easily get the issuing key from the config with the following description.

  ```elixir
  config :your_app, Your.Module,
    kid: "kid20200914",
    keys: [["kid20200914", "HS256", "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"]]
  ```

  The key specified by `:kid` must be included in `:keys`.

  ```elixir
  @config Application.fetch_env!(:your_app, Your.Module)

  kb_jwk_to_issue = find_key_to_issue(@config)
  ```
  """
  @spec find_key_to_issue(config :: Keyword.t()) :: t() | nil
  def find_key_to_issue(config) do
    with keys <- config |> Keyword.fetch!(:keys) |> KittenBlue.JWK.compact_to_list(),
         kid <- config |> Keyword.fetch!(:kid) do
      Enum.find(keys, fn kb_jwk -> kb_jwk.kid == kid end)
    end
  end

  @doc """
  Function to return JWK Thumbprint
  """
  @spec to_thumbprint(jwk :: t()) :: String.t()
  def to_thumbprint(jwk) do
    JOSE.JWK.thumbprint(:sha256, jwk.key)
  end
end