lib/token_manager.ex

defmodule ExMicrosoftBot.TokenManager do
  @moduledoc """
  This module is a GenServer that handles getting access token from
  Microsoft bot framework and also is responsible for refreshing the token
  before it expires.
  """

  use ExMicrosoftBot.RefreshableAgent

  import ExMicrosoftBot.Client, only: [opts: 0]

  alias ExMicrosoftBot.{Client, Models}

  @auth_api_endpoint "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"
  @scope Application.get_env(:ex_microsoftbot, :scope, "https://api.botframework.com/.default")

  # Public API

  @doc """
  Get the token that can be used to authorize calls to Microsoft Bot Framework
  """
  def get_token() do
    case get_state() do
      {:ok, %{token: token}} -> token
      _ -> nil
    end
  end

  # Refreshable Agent Callbacks

  @time_gap_to_refresh_in_seconds 500
  @time_to_refresh_on_error_in_seconds 15

  @doc """
  Refresh the token by making a service call and then scheduling a message to
  this GenServer before token expires so that it can be refreshed
  """
  def get_refreshed_state(%Models.AuthData{} = auth_data, _old_state) do
    :ex_microsoftbot
    |> Application.get_env(:using_bot_emulator)
    |> do_get_refreshed_state(auth_data)
  end

  @doc """
  The time to refresh which is taken from the response of the token
  """
  def time_to_refresh_after_in_seconds({:ok, %{expiry_in_seconds: expiry_in_seconds}}) do
    expiry_in_seconds - @time_gap_to_refresh_in_seconds
  end

  def time_to_refresh_after_in_seconds({:error, _, _}) do
    @time_to_refresh_on_error_in_seconds
  end

  # Private

  defp do_get_refreshed_state(_emulator? = true, %Models.AuthData{} = _auth_data) do
    {:ok, %{token: "TestToken", expiry_in_seconds: 36000}}
  end

  defp do_get_refreshed_state(__emulator?, %Models.AuthData{} = auth_data) do
    auth_data
    |> refresh_token()
    |> validate_token()
  end

  defp validate_token({:ok, %{token: token} = token_response}) do
    # TODO: See what other checks are needed to verify the JWT
    with jwt <- JOSE.JWT.peek_payload(token),
         true <- contains_valid_app_id_claim?(jwt) do
      {:ok, token_response}
    else
      result ->
        Logger.error("Error validating token. Result: #{inspect(result)}")
        {:error, result}
    end
  end

  defp validate_token({:error, _status_code, _body} = error) do
    Logger.error("Error validating token. Result: #{inspect(error)}")
    error
  end

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

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

  defp contains_valid_app_id_claim?(_, %JOSE.JWT{}), do: false

  defp refresh_token(%Models.AuthData{} = auth_data) do
    case get_token_from_service(auth_data) do
      {:ok, token_response} ->
        {:ok,
         %{
           token: Map.get(token_response, "access_token"),
           expiry_in_seconds: Map.get(token_response, "expires_in")
         }}

      {:error, _status_code, _body} = error ->
        Logger.error("Error refreshing token. Result: #{inspect(error)}")
        error
    end
  end

  defp get_token_from_service(%Models.AuthData{app_id: app_id, app_password: app_password}) do
    auth_api_endpoint =
      Application.get_env(:ex_microsoftbot, :auth_api_endpoint) || @auth_api_endpoint

    body =
      URI.encode_query(%{
        grant_type: "client_credentials",
        client_id: app_id,
        client_secret: app_password,
        scope: @scope
      })

    headers = ["Content-Type": "application/x-www-form-urlencoded"]

    auth_api_endpoint
    |> HTTPoison.post(body, headers, opts())
    |> Client.deserialize_response(&Poison.decode!(&1, as: %{}))
  end
end