Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create elixir_auth_facebook v1 #21

Open
5 tasks
nelsonic opened this issue Oct 14, 2022 · 10 comments
Open
5 tasks

Create elixir_auth_facebook v1 #21

nelsonic opened this issue Oct 14, 2022 · 10 comments
Labels
enhancement New feature or enhancement of existing functionality help wanted If you can help make progress with this issue, please comment! priority-3 Third priority. Considered "Nice to Have". Not urgent.

Comments

@nelsonic
Copy link
Member

nelsonic commented Oct 14, 2022

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:

  • make the facts about Meta clear at the bottom of the README.md.
    And advise people to only use this package as a last resort for allowing people who have no other option.
@nelsonic nelsonic added enhancement New feature or enhancement of existing functionality help wanted If you can help make progress with this issue, please comment! labels Oct 14, 2022
nelsonic added a commit that referenced this issue Oct 14, 2022
nelsonic added a commit that referenced this issue Oct 14, 2022
nelsonic added a commit that referenced this issue Oct 14, 2022
@ndrean
Copy link
Collaborator

ndrean commented Oct 15, 2022

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
Copy link
Collaborator

ndrean commented Oct 15, 2022

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
Copy link
Collaborator

ndrean commented Oct 15, 2022

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
Copy link
Collaborator

ndrean commented Oct 16, 2022

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

@ndrean
Copy link
Collaborator

ndrean commented Oct 16, 2022

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
Copy link
Collaborator

ndrean commented Oct 17, 2022

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

@nelsonic
Copy link
Member Author

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

@ndrean
Copy link
Collaborator

ndrean commented Oct 18, 2022

“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
Copy link
Collaborator

ndrean commented Oct 19, 2022

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

@nelsonic
Copy link
Member Author

@ndrean please assign PR to me when you feel it's ready for review. 🙏

@nelsonic nelsonic added the priority-3 Third priority. Considered "Nice to Have". Not urgent. label Nov 11, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or enhancement of existing functionality help wanted If you can help make progress with this issue, please comment! priority-3 Third priority. Considered "Nice to Have". Not urgent.
Projects
None yet
Development

No branches or pull requests

2 participants