defmodule FlowMailer.Token do @moduledoc """ Token manager for FlowMailer service. It is a GenServer that manages FlowMailer authorization tokens, supports multiple accounts. """ use GenServer alias FlowMailer.Token import FlowMailer.Shared @type t :: %__MODULE__{ updated_at: DateTime.t(), access_token: AccessToken.t() } defstruct updated_at: nil, access_token: nil defmodule AccessToken do @moduledoc """ FlowMailer Access Token structure """ @type t :: %__MODULE__{ access_token: String.t(), token_type: String.t(), expires_in: non_neg_integer, scope: String.t() } defstruct [ :access_token, :token_type, :expires_in, :scope ] end @client_id_key :flowmailer_client_id @account_id_key :flowmailer_account_id @client_secret_key :flowmailer_client_secret @auth_url "https://login.flowmailer.net/oauth/token" def get(config) do GenServer.call(__MODULE__, {:get_token, config}) end def handle_config(config) do validate_params(config) GenServer.cast(__MODULE__, {:configure, config}) end @impl true def init(_opts) do {:ok, %{}} end def start_link(_opts) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @impl true def handle_cast({:configure, %{flowmailer_client_id: client_id}}, state) do new_state = Map.put_new(state, client_id, %Token{}) {:noreply, new_state} end @impl true def handle_call({:get_token, config}, _from, state) do client_id = get_config_value(config, @client_id_key) result = case Map.get(state, client_id) do nil -> add_new_token(client_id, state, config) %Token{updated_at: nil} -> do_refresh_token(client_id, state, config) %Token{} -> maybe_refresh_token(client_id, state, config) end case result do {:ok, state} -> %Token{access_token: access_token} = Map.get(state, client_id) %AccessToken{access_token: token_value} = access_token {:reply, {:ok, token_value}, state} {:error, error, state} -> {:reply, {:error, error}, state} end end defp add_new_token(client_id, state, config) do do_refresh_token(client_id, state, config) end defp maybe_refresh_token(client_id, state, config) do %Token{updated_at: updated_at, access_token: access_token = %AccessToken{}} = Map.get(state, client_id) expire_ts = DateTime.add(updated_at, access_token.expires_in - 2, :second) expires? = DateTime.diff(expire_ts, DateTime.utc_now()) < 0 if expires? do {result, state} = do_refresh_token(client_id, state, config) {result, state} else {:ok, state} end end defp do_refresh_token(client_id, state, config) do case fetch_token(config) do {:ok, token_data} -> new_state = Map.put(state, client_id, %Token{ updated_at: DateTime.utc_now(), access_token: token_to_struct(token_data) }) {:ok, new_state} {:error, error} -> {:error, error, state} end end defp token_to_struct(data) do %AccessToken{ access_token: data["access_token"], expires_in: data["expires_in"], scope: data["scope"], token_type: data["token_type"] } end defp fetch_token(config) do client_id = get_config_value(config, @client_id_key) client_secret = get_config_value(config, @client_secret_key) body = URI.encode_query(%{ "client_id" => client_id, "client_secret" => client_secret, "grant_type" => "client_credentials", "scope" => "api" }) headers = [ {"content-type", "application/x-www-form-urlencoded"} ] case :hackney.post(@auth_url, headers, body, hackney_opts(config)) do {:ok, code, _headers, response} when code < 299 -> body = response |> Bamboo.json_library().decode!() {:ok, body} {:ok, code, _, response} -> {:error, %{code: code, response: response}} error -> {:error, error} end rescue r -> {:error, "Error on fetching FlowMailer token: #{inspect(r)}"} end defp validate_param(config, param) do _value = get_config_value(config, param) config end defp validate_params(config) do config |> validate_param(@client_id_key) |> validate_param(@account_id_key) |> validate_param(@client_secret_key) end end