Open nelsonic opened 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.
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
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
}
}
I will refactor closer to your style with HTTPoison.get!
.
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.
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
Stupid question but... I have no right to push code to a non-existing branch. How do I git push remote ssr
???
@ndrean you should have full write access to push your branch. Please confirm. π€πΌ
β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.
I made two branches: SDK and SSR I believe SDK is ok, not SSR yet. Can you review it?
@ndrean please assign PR to me when you feel it's ready for review. π
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 thatFacebook
is the Internet π totry
ourApp
with the least possible friction. π±Todo
new
mix
project πhex.pm
project under the dwyl org. :shipit:We will also:
make
thefacts
aboutMeta
clear at the bottom of theREADME.md
. And advise people to only use this package as a last resort for allowing people who have no otheroption
.