dwyl / elixir-auth-facebook

:busts_in_silhouette: Easy Facebook Authentication for Elixir Apps
GNU General Public License v2.0
15 stars 3 forks source link

Create `elixir_auth_facebook` v1 #21

Open nelsonic opened 1 year ago

nelsonic commented 1 year ago

Our objective with this is not to perpetuate the Facebook dystopia, πŸ‘Ž Rather it is simply to have a way to allow people who have been suckered into thinking that Facebook is the Internet πŸ™„ to try our App with the least possible friction. πŸ“±

Todo

We will also:

ndrean commented 1 year ago

OK. Done the SSR. 10h!!! Couldn't find how to reach for the profile endpoint. Definitely more work to do than using the Facebook snippet client-side. Saying that I never use FB😏 but it is so convenient to have it.

My draft will follow.

ndrean commented 1 year ago
defmodule ElixirAuthFacebook do
  @moduledoc """
Snippet to get a SSR Facebook Login link in five steps from <https://developers.facebook.com/docs/facebook-login/>. 
Once you set up an app in the "developpers.facebook.com/app"  portal,  
you follow the four steps below and use this module with a simple call in a controller:

{:ok, profile} = ElixirAuthFacebook.handle_callback(conn, params)

where the user receives - from his public_profile - the email, the facebook_id, the name, the FB_token 
and expiration.

The error handling defaults to the function:
def terminate(conn, message, path) do
    conn
    |> Phoenix.Controller.put_flash(:error, inspect(message))
    |> Phoenix.Controller.redirect(to: path)
    |> Plug.Conn.halt()
  end

You can override it with your own termination in the controller with
ElixirAuthFacebook.handle_callback(conn, params, &my_termination/3) 

For example, you can define
def my_termination(conn, _, path), do:
  Phoenix.Controller.redirect(conn, to: path) |> Plug.COnn.halt()

Steps
1. Add a link, say in your index.html:

<a class="" href={@oauth_facebook_url}>
  <img src={Routes.static_path(@conn, "/images/fb_login.png")} style="margin-left: 120px;"/>
</a>

2. declare a route:
get "/auth/facebook/callback", FacebookAuthController, :index

3. build a controller FacebookAuthController where you can define your own error termination
{:ok, profile} = ElixirAuthFacebook.handle_callback(conn, params, <&termination/3>)

4.  add "/my_app_web/views/facebook_auth_view.ex" file.

Don't forget to have fun with <https://developers.facebook.com/apps/?show_reminder=true>. 
Save the credentials in `.env` file and `$ source .env` to have these env vars (check with `$ env`):

.env
export FACEBOOK_APP_ID=XXXXX
export FACEBOOK_APP_SECRET=XXXXX

and/or in your config <-- TODO!
"""

  @default_callback_path "https://localhost:4443/auth/facebook/callback"
  @default_scope "public_profile"
  @auth_type "rerequest"
  @fb_dialog_oauth "https://www.facebook.com/v15.0/dialog/oauth?"
  @fb_access_token "https://graph.facebook.com/v15.0/oauth/access_token?"
  @fb_debug "https://graph.facebook.com/debug_token?"
  @fb_profile "https://graph.facebook.com/v15.0/me?fields=id,email,name,picture"

 def app_id(), do:
      System.get_env("FACEBOOK_APP_ID") || Application.get_env(:elixir_auth_facebook, :app_id)

  def app_secret(), do:
      System.get_env("FACEBOOK_APP_SECRET") || Application.get_env(:elixir_auth_facebook, :app_secret)

  def app_access_token(), do: app_id() <> "|" <> app_secret()

  @doc """
  Generate URI for first access with temporary "code" from users' credentials.
  We also inject a "salt" and the APP_ID and we check if our "salt" is happily returned
  """
  def generate_oauth_url() do
    @fb_dialog_oauth <> params_1()
  end

  @doc """
  Generate URI for the second query to receive the access_token from the "code"
  """
  def get_access_token(code) do
    @fb_access_token <> params_2(code)
  end

  @doc """
  Third query to verify.
  """
  defp debug_token(token) do
    @fb_debug <> params_3(token)
  end

  @doc """
  Fetch user's profile
  """
  def graph_api(), do: @fb_profile

  @doc """
  Default function to terminate errors. Used flash, redirect, but can be modified...
  """
  def terminate(conn, message, path) do
    conn
    |> Phoenix.Controller.put_flash(:error, inspect(message))
    |> Phoenix.Controller.redirect(to: path)
    |> Plug.Conn.halt()
  end

  def handle_callback(conn, params, term \\ &terminate/3)

  def handle_callback(conn, %{"error" => error}, term) do
    term.(conn, error, "/")
  end

  @doc """
  We should receive the "state" aka as "salt" back as a CSRF check after the dialog.
  """
  def handle_callback(conn, %{"state" => state, "code" => code} = params, term) do
    keys = Map.keys(params)

    with {:salt, true} <- {:salt, check_salt(state)},
         {:code, true} <- {:code, "code" in keys} do
      fb_oauth = get_access_token(code)

      case HTTPoison.get(fb_oauth) do
        {:error, %HTTPoison.Error{id: nil, reason: err}} ->
          term.(conn, err, "/")

        {:ok, %HTTPoison.Response{body: body}} ->
         case Jason.decode!(body) do
            %{"error" => %{"message" => message}} ->
              term.(conn, message, "/")

            body ->
              conn
              |> Plug.Conn.assign(:body, body)
              |> Plug.Conn.assign(:term, term)
              |> get_login()
              |> get_profile()
           end
      end
    else
      {:salt, false} ->
        term.(conn, "salt false", "/")

      {:code, false} ->
        term.(conn, "code false", "/")
    end
  end

 @doc"""
  Terminate if user does not accept the Login dialog
 """ 
 def get_login(%Plug.Conn{assigns: %{body: %{"error" => %{"message" => message}}}} = conn) do
    conn.assigns.term.(conn, message, "/")
  end

  def get_login(%Plug.Conn{assigns: %{body: %{"access_token" => token}}} = conn) do
    term = conn.assigns.term
    case HTTPoison.get(debug_token(token)) do
       {:error, %HTTPoison.Error{id: nil, reason: err}} ->
        term.(conn, err, "/")

      {:ok, %HTTPoison.Response{body: body}} ->

        case Jason.decode!(body) do
          %{"error" => %{"message" => message}} ->
            term.(conn, message, "/")

          %{"data" => data} ->
            %{"user_id" => fb_id, "is_valid" => valid, "expires_at" => exp} = data

            conn
            |> Plug.Conn.assign(:token, token)
            |> Plug.Conn.assign(:exp, exp)
            |> Plug.Conn.assign(:valid, valid)
            |> Plug.Conn.assign(:fb_id, fb_id)
        end
    end
  end

  @doc"""
   Access token too old or user banned or ...
  """
  def get_profile(%Plug.Conn{assigns: %{valid: false}} = conn) do
    conn.assigns.term.(conn, "renew your credentials", "/")
  end

  def get_profile(%Plug.Conn{assigns: %{token: token, fb_id: fb_id, exp: exp}} = conn) do
    access_token = URI.encode_query(%{"access_token" => token})
    me_point = graph_api() <> "&" <> access_token

    term = conn.assigns.term

    case HTTPoison.get(me_point) do
      {:error, %HTTPoison.Error{id: nil, reason: err}} ->
        term.(conn, err, "/")

      {:ok, %HTTPoison.Response{body: body}} ->
        case Jason.decode!(body) do
          %{"error" => %{"message" => message}} ->
            term.(conn, message, "/")

          %{"email" => email, "id" => fb_id, "name" => name, "picture" => avatar} ->
            {:ok, %{ email: email, fb_id: fb_id, name: name, avatar: avatar, token: token, exp: exp}}
    end
  end
end

  @doc""" 
  We used the salt from the app Endpoint
  """
  def get_salt() do
    Application.get_env(:live_map, LiveMapWeb.Endpoint)
    |> List.keyfind(:live_view, 0)
    |> then(fn {:live_view, [signing_salt: salt]} ->
      salt
    end)
  end

  def check_salt(state) do
    get_salt() == state
  end

  defp params_1() do
    URI.encode_query(%{
      "client_id" => app_id(),
      "state" => get_salt(),
      "redirect_uri" => @default_callback_path,
      "scope" => @default_scope
    })
  end

  defp params_2(code) do
    URI.encode_query(%{
      "client_id" => app_id(),
      "state" => get_salt(),
      "redirect_uri" => @default_callback_path,
      "code" => code,
      "client_secret" => app_secret()
    })
  end

  defp params_3(token) do
    URI.encode_query(%{
      "access_token" => app_access_token(),
      "input_token" => token
    })
  end
end
ndrean commented 1 year ago

I used Caddy to run HTTPS locally for the client snippet, so the app was reached at https://localhost:4443 caddy run --watch

Caddyfile

localhost:4443 {
    handle {
        reverse_proxy 127.0.0.1:4000
    }
}
ndrean commented 1 year ago

I will refactor closer to your style with HTTPoison.get!.

ndrean commented 1 year ago

Closer to your standards I believe with less "case" and more destructuring in the arguments

defmodule ElixirAuthFacebook do
  @moduledoc """
  Snippet to enable Facebook Login
  Termination function is optional
  Two functions are exposed: "generate_oauth_url" and "handle_callback"
  """

  @default_callback_path "auth/facebook/callback"
  @default_scope "public_profile"
  @fb_dialog_oauth "https://www.facebook.com/v15.0/dialog/oauth?"
  @fb_access_token "https://graph.facebook.com/v15.0/oauth/access_token?"
  @fb_debug "https://graph.facebook.com/debug_token?"
  @fb_profile "https://graph.facebook.com/v15.0/me?fields=id,email,name,picture"

  # ------ Definition of Credentials
  def app_id(),
    do:
      System.get_env("FACEBOOK_APP_ID") ||
        Application.get_env(:elixir_auth_facebook, :app_id) ||
        raise("""
        App ID missing
        """)

  def app_secret() do
    System.get_env("FACEBOOK_APP_SECRET") ||
      Application.get_env(:elixir_auth_facebook, :app_secret) ||
      raise """
      App secret missing
      """
  end

  defp app_access_token(), do: app_id() <> "|" <> app_secret()

  # -------- callback URL
  defp check_callback_url(url) do
    if String.at(url, 0) == "/",
      do:
        raise("""
        Bad callback url. It must NOT start with "/"
        """)
  end

  @doc """
  derives the URL from the "conn" struct and the input
  """
  defp generate_redirect_url(%Plug.Conn{host: "localhost"}) do
    check_callback_url(@default_callback_path)

    "http://localhost:4000/" <> @default_callback_path
  end

  defp generate_redirect_url(%Plug.Conn{scheme: sch, host: h} = _conn) do
    check_callback_url(@default_callback_path)

    Atom.to_string(sch) <>
      "://" <>
      h <>
      @default_callback_path
  end

  # ------- Definition of Dialog Login entry point

  @doc """
  Generates the url that opens Login dialog.
  A "state" test is injected to prevent CSRF.
  """
  def generate_oauth_url(conn) do
    @fb_dialog_oauth <> params_1(conn)
  end

  # ---------- Definition of the URLs
  @doc """
  Generates the url for the exchange "code" to "access_token".
  """
  defp access_token_uri(code, conn) do
    @fb_access_token <> params_2(code, conn)
  end

  @doc """
  Generates the url for Access Token inspection.
  """
  defp debug_token_uri(token), do: @fb_debug <> params_3(token)

  @doc """
  Generates the url for session info
  """
  defp session_info_url(token) do
    @fb_access_token <>
      "grant_type=fb_attenuate_token&client_id=" <>
      app_id() <>
      "&fb_exchange_token=" <>
      token
  end

  @doc """
  Generates the Graph API url to query for users data.
  """
  defp graph_api(access), do: @fb_profile <> "&" <> access

  # ------- Error handling function
  @doc """
  Function to document how to terminate errors. Use flash, redirect...
  """
  def terminate(conn, message, path) do
    conn
    |> Phoenix.Controller.put_flash(:error, inspect(message))
    |> Phoenix.Controller.redirect(to: path)
    |> Plug.Conn.halt()
  end

  # ------- MAIN
  def handle_callback(conn, params, term \\ &terminate/3)

  # User denies Login dialog
  def handle_callback(conn, %{"error" => message}, term) do
    term.(conn, {:handle_callback, message}, "/")
  end

  @doc """
  We receive the "state" aka as "salt" we sent.
  """
  def handle_callback(conn, %{"state" => state, "code" => code}, term) do
    conn = Plug.Conn.assign(conn, :term, term)

    case check_salt(state) do
      false ->
        term.(conn, "salt false", "/")

      true ->
        code
        |> access_token_uri(conn)
        |> HTTPoison.get!()
        |> Map.get(:body)
        |> Jason.decode!()
        |> then(fn data ->
          conn
          |> Plug.Conn.assign(:data, data)
          |> get_data()
          |> get_session_info()
          |> get_profile()
          |> check_profile()
        end)
    end
  end

  defp get_data(%Plug.Conn{assigns: %{data: %{"error" => %{"message" => message}}}} = conn) do
    conn.assigns.term.(conn, {:get_data, message}, "/")
  end

  defp get_data(%Plug.Conn{assigns: %{data: %{"access_token" => token}}} = conn) do
    token
    |> debug_token_uri()
    |> HTTPoison.get!()
    |> Map.get(:body)
    |> Jason.decode!()
    |> Map.get("data")
    |> then(fn data ->
      conn
      |> Plug.Conn.assign(:data, data)
      |> Plug.Conn.assign(:access_token, token)
      |> Plug.Conn.assign(:is_valid, data["is_valid"])
    end)
  end

  defp get_session_info(
         %Plug.Conn{assigns: %{data: %{"error" => %{"message" => message}}}} = conn
       ) do
    conn.assigns.term.(conn, {:get_session, message}, "/")
  end

  defp get_session_info(%Plug.Conn{assigns: %{is_valid: nil}} = conn) do
    conn.assigns.term.(conn, {:get_session, "renew your credentials"}, "/")
  end

  defp get_session_info(%Plug.Conn{assigns: %{access_token: token}} = conn) do
    token
    |> session_info_url()
    |> HTTPoison.get!()
    |> Map.get(:body)
    |> Jason.decode!()
    |> then(fn data ->
      conn
      |> Plug.Conn.assign(:session_info, data["access_token"])
    end)
  end

  defp get_profile(%Plug.Conn{assigns: %{data: %{"error" => %{"message" => message}}}} = conn) do
    conn.assigns.term.(conn, {:get_profile, message}, "/")
  end

  defp get_profile(%Plug.Conn{assigns: %{is_valid: nil}} = conn) do
    conn.assigns.term.(conn, {:get_profile, "renew your credentials"}, "/")
  end

  defp get_profile(%Plug.Conn{assigns: %{session_info: nil}} = conn) do
    conn.assigns.term.(conn, {:get_profile_session, "renew your credentials"}, "/")
  end

  defp get_profile(%Plug.Conn{assigns: %{access_token: token, is_valid: true}} = conn) do
    URI.encode_query(%{"access_token" => token})
    |> graph_api()
    |> HTTPoison.get!()
    |> Map.get(:body)
    |> Jason.decode!()
    |> then(fn data ->
      Plug.Conn.assign(conn, :profile, data)
    end)
  end

  defp check_profile(
         %Plug.Conn{assigns: %{profile: %{"error" => %{"message" => message}}}} = conn
       ) do
    conn.assigns.term.(conn, {:check_profile, message}, "/")
  end

  defp check_profile(%Plug.Conn{
         assigns: %{access_token: token, session_info: session_info, profile: profile}
       }) do
    profile =
      for({k, v} <- profile, into: %{}, do: {String.to_atom(k), v})
      |> Map.merge(%{access_token: token})
      |> Map.merge(%{session_info: session_info})
      |> exchange_id()
      |> dbg()

    {:ok, profile}
  end

  # ---------- Helper on cleaning the profile
  @doc """
  Facebook gives and ID. We replace "id" to "fb_id" to avoid confusion in the returned data
  """
  defp exchange_id(profile) do
    profile
    |> Map.put_new(:fb_id, profile.id)
    |> Map.delete(:id)
  end

  # ---------- Helpers on salt and query params
  defp get_salt() do
    Application.get_env(:live_map, LiveMapWeb.Endpoint)
    |> List.keyfind(:live_view, 0)
    |> then(fn {:live_view, [signing_salt: val]} ->
      val
    end) ||
      raise """
      Missing Endpoint signing salt
      """
  end

  defp check_salt(state) do
    get_salt() == state
  end

  defp params_1(conn) do
    URI.encode_query(%{
      "client_id" => app_id(),
      "state" => get_salt(),
      "redirect_uri" => generate_redirect_url(conn),
      "scope" => @default_scope
    })
  end

  defp params_2(code, conn) do
    URI.encode_query(%{
      "client_id" => app_id(),
      "state" => get_salt(),
      "redirect_uri" => generate_redirect_url(conn),
      "code" => code,
      "client_secret" => app_secret()
    })
  end

  defp params_3(token) do
    URI.encode_query(%{
      "access_token" => app_access_token(),
      "input_token" => token
    })
  end
end

and use in a controller like this:

def login(conn, _p) do
{:ok, profile} = ElixirAuthFacebook.handle_callback(conn, params)

    with %{email: email} <- profile do
      user = LiveMap.User.new(email)
      user_token = LiveMap.Token.user_generate(user.id)

      conn
      |> put_session(:user_token, user_token)
      |> put_session(:user_id, user.id)
      |> put_session(:profile, profile)
      |> redirect(to: "/welcome")
      |> halt()
    else
      _ -> render(conn, "index.html")
    end

The action starts here:

<a class="" href={@oauth_facebook_url}>
  <img src={Routes.static_path(@conn, "/images/fb_login.png")} style="margin-left: 120px;"/>
</a>

OK, not the best image, I will need to spend more time to get one.

fb_login

and the router:

scope "/auth", LiveMapWeb do
    pipe_through :browser

    get "/google/callback", GoogleAuthController, :login
    get "/github/callback", GithubAuthController, :login
    get "/facebook/callback", FacebookAuthController, :login
  end
ndrean commented 1 year ago

Stupid question but... I have no right to push code to a non-existing branch. How do I git push remote ssr???

nelsonic commented 1 year ago

@ndrean you should have full write access to push your branch. Please confirm. 🀞🏼

ndrean commented 1 year ago

β€œfork and branch” workflow looks something like this:

Fork a GitHub repository. Clone the forked repository to your local system. Add a Git remote for the original repository. Create a feature branch in which to place your changes. Make your changes to the new branch. Commit the changes to the branch. Push the branch to GitHub. Open a pull request from the new branch to the original repo. Clean up after your pull request is merged.

ndrean commented 1 year ago

I made two branches: SDK and SSR I believe SDK is ok, not SSR yet. Can you review it?

nelsonic commented 1 year ago

@ndrean please assign PR to me when you feel it's ready for review. πŸ™