lib/token_validation.ex

defmodule ExMicrosoftBot.TokenValidation do
  @moduledoc """
  This module provides functions to validate the authorization token recived by the bot service
  from the Microsoft Bot Framework
  """

  require Logger
  alias ExMicrosoftBot.SigningKeysManager

  @doc """
  Helper function to validate the authentication information for the bot
  """
  @spec validate_bot_credentials?(Keyword.t()) :: boolean
  def validate_bot_credentials?(headers) do
    Logger.debug(
      "ExMicrosoftBot.TokenValidation.validate_bot_credentials?: Going to validate the bot credentials"
    )

    validate_bot_credentials?(
      headers,
      Application.get_env(:ex_microsoftbot, :disable_token_validation)
    )
  end

  @spec validate_bot_credentials?(Keyword.t(), boolean) :: boolean
  defp validate_bot_credentials?(_headers, true) do
    Logger.debug(
      "ExMicrosoftBot.TokenValidation.validate_bot_credentials?: Going to skip token validation as its an emulator"
    )

    # In case bot emulator is used ignore the credentials
    true
  end

  defp validate_bot_credentials?(headers, _) do
    # Convert the list of key value tuple to a map and get the authorization header
    auth_header =
      Enum.reduce(headers, %{}, fn {k, v}, acc -> Map.put(acc, k, v) end)
      |> Map.get("authorization", nil)

    case auth_header do
      "Bearer " <> auth_token ->
        validate_auth_token(auth_token)

      _ ->
        Logger.debug("ExMicrosoftBot.TokenValidation.validate_bot_credentials? Failed")
        false
    end
  end

  defp validate_auth_token(token) do
    with {:ok, jwt, _jws} <- get_jwt_from_string(token),
         true <- contains_valid_issuer?(jwt),
         true <- contains_valid_audience?(jwt),
         true <- contains_valid_app_id_claim?(jwt),
         true <- token_not_expired?(jwt),
         true <- has_valid_cryptographic_sig?(token) do
      Logger.debug("ExMicrosoftBot.TokenValidation.validate_auth_token: Passed")
      true
    else
      failure_value ->
        Logger.debug(
          "ExMicrosoftBot.TokenValidation.validate_auth_token: Failed #{inspect(failure_value)}"
        )

        false
    end
  end

  defp get_jwt_from_string(token) do
    Logger.debug("ExMicrosoftBot.TokenValidation.get_jwt_from_string: Going to parse token")
    {:ok, JOSE.JWT.peek_payload(token), JOSE.JWT.peek_protected(token)}
  rescue
    _e ->
      Logger.debug("ExMicrosoftBot.TokenValidation.get_jwt_from_string: Failed")
      {:error, "Unable to parse the token"}
  end

  defp expected_issuer_claim, do: Application.get_env(:ex_microsoftbot, :issuer_claim)

  defp contains_valid_issuer?(%JOSE.JWT{} = jwt),
    do: contains_valid_issuer?(expected_issuer_claim(), jwt)

  defp contains_valid_issuer?(issuer_claim, %JOSE.JWT{fields: %{"iss" => issuer_claim}}), do: true

  defp contains_valid_issuer?(_issuer_claim, %JOSE.JWT{}) do
    Logger.debug("ExMicrosoftBot.TokenValidation.contains_valid_issuer? Failed")
    false
  end

  defp expected_audience_claim, do: Application.get_env(:ex_microsoftbot, :app_id)

  defp contains_valid_audience?(%JOSE.JWT{} = jwt),
    do: contains_valid_audience?(expected_audience_claim(), jwt)

  defp contains_valid_audience?(audience_claim, %JOSE.JWT{fields: %{"aud" => audience_claim}}),
    do: true

  defp contains_valid_audience?(_audience_claim, %JOSE.JWT{}) do
    Logger.debug("ExMicrosoftBot.TokenValidation.contains_valid_audience? Failed")
    false
  end

  defp contains_valid_app_id_claim?(%JOSE.JWT{} = jwt) do
    contains_valid_app_id_claim?(jwt, Application.get_env(:ex_microsoftbot, :using_bot_emulator))
  end

  defp contains_valid_app_id_claim?(%JOSE.JWT{fields: %{"appid" => app_id}}, true),
    do: app_id == Application.get_env(:ex_microsoftbot, :app_id)

  defp contains_valid_app_id_claim?(token, true) do
    # In case extra bot validation is required and app id isn't in claim then fail
    Logger.debug(
      "ExMicrosoftBot.TokenValidation.contains_valid_app_id_claim? Failed. Token received #{
        inspect(token)
      }"
    )

    false
  end

  # This will occur for prod
  defp contains_valid_app_id_claim?(_, _), do: true

  defp token_not_expired?(%JOSE.JWT{fields: %{"exp" => expiry}}) do
    with expiry_time <- Timex.from_unix(expiry),
         time_to_compare_with <- Timex.now() |> Timex.shift(minutes: 5),
         true <- Timex.before?(time_to_compare_with, expiry_time) do
      true
    else
      _ ->
        Logger.debug("ExMicrosoftBot.TokenValidation.token_not_expired? Failed")
        false
    end
  end

  defp has_valid_cryptographic_sig?(token) do
    case SigningKeysManager.get_keys() do
      {:ok, keys} ->
        keys
        |> Enum.map(fn key ->
          JOSE.JWT.verify(key, token)
        end)
        |> Enum.map(fn {val, _, _} -> val end)
        |> Enum.filter(fn val -> val == true end)
        |> length
        |> Kernel.>(0)

      {:error, _} ->
        false
    end
  end
end