-
Notifications
You must be signed in to change notification settings - Fork 3
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
Comments
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. 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 Caddyfile
|
I will refactor closer to your style with |
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 |
@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. |
I made two branches: SDK and SSR |
@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 that
Facebook
is the Internet 🙄to
try
ourApp
with the least possible friction. 📱Todo
new
mix
project 🆕hex.pm
project under the dwyl org.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 other
option
.The text was updated successfully, but these errors were encountered: