lib/ueberauth/strategy/duo.ex

defmodule Ueberauth.Strategy.Duo do
  use Ueberauth.Strategy,
    uid_field: :sub,
    default_scope: "openid email profile"

  alias Plug.Conn
  alias Ueberauth.Auth.{Info, Credentials, Extra}
  alias Ueberauth.Strategy.Duo.OAuth

  @impl Ueberauth.Strategy
  def handle_request!(conn) do
    scopes = conn.params["scope"] || option(conn, :default_scope)
    params = [scope: scopes] |> with_state_param(conn)
    opts   = options(conn) |> Keyword.put(:redirect_uri, callback_url(conn))

    redirect!(conn, OAuth.authorize_url!(params, opts))
  end

  @impl Ueberauth.Strategy
  def handle_callback!(%Conn{params: %{"code" => code}} = conn) do
    opts  = options(conn) |> Keyword.put(:redirect_uri, callback_url(conn))
    client = OAuth.get_token!([code: code], opts)
    token  = client.token

    case token do
      nil ->
        err = token.other_params["error"]
        desc = token.other_params["error_description"]
        set_errors!(conn, [error(err, desc)])

      _token ->
        fetch_user(conn, token)
    end
  rescue
    err in [Error] ->
      set_errors!(conn, [error("OAuth2", err.reason)])
  end

  @impl Ueberauth.Strategy
  def handle_callback!(%Conn{params: %{"error" => key, "error_description" => message}} = conn) do
    set_errors!(conn, [error(key, message)])
  end

  @doc """
  Cleans up the private area of the connection used for passing the raw Duo response around during the callback.
  """
  @impl Ueberauth.Strategy
  def handle_cleanup!(conn) do
    conn
    |> put_private(:duo_user, nil)
    |> put_private(:duo_token, nil)
  end

  @doc """
  Fetches the fields to populate the info section of the `Ueberauth.Auth` struct.
  """
  @impl Ueberauth.Strategy
  def info(conn) do
    user = conn.private.duo_user

    %Info{
      email: user["email"],
      first_name: user["given_name"],
      last_name: user["family_name"],
      name: user["name"],
    }
  end

  @doc """
  Stores the raw information (including the token) obtained from the Duo callback.
  """
  @impl Ueberauth.Strategy
  def extra(conn) do
    %Extra{
      raw_info: %{
        token: conn.private.duo_token,
        user: conn.private.duo_user
      }
    }
  end

  @doc """
  Includes the credentials from the Duo response.
  """
  @impl Ueberauth.Strategy
  def credentials(conn) do
    token = conn.private.duo_token

    %Credentials{
      token: token.access_token,
      token_type: token.token_type,
      refresh_token: token.refresh_token,
      expires: token.expires_at != nil,
      expires_at: token.expires_at,
      scopes: token.other_params["scope"]
    }
  end

  @doc """
  Fetches the uid field from the Duo response. This defaults to the option `uid_field` which in-turn defaults to `sub`
  """
  @impl Ueberauth.Strategy
  def uid(conn) do
    conn
    |> option(:uid_field)
    |> to_string()
    |> fetch_uid(conn)
  end

  defp fetch_uid(field, conn) do
    conn.private.duo_user[field]
  end

  defp fetch_user(conn, token) do
    conn = put_private(conn, :duo_token, token)
    opts = options(conn) |> Keyword.put(:token, token)

    with {:ok, user} <- Ueberauth.Strategy.Duo.OAuth.get_user_info(_headers = [], opts) do
      put_private(conn, :duo_user, user)
    else
      {:error, %OAuth2.Error{reason: reason}} ->
        set_errors!(conn, [error("OAuth2", inspect(reason))])

      {:error, %{status_code: 401}} ->
        set_errors!(conn, [error("Duo token [401]", "unauthorized")])

      {:error, %{status_code: status, body: body}} when status in 400..599 ->
        set_errors!(conn, [error("Duo [#{status}]", inspect(body))])
    end
  end

  defp option(conn, key) do
    default = Keyword.get(default_options(), key)

    conn
    |> options()
    |> Keyword.get(key, default)
  end

end