From 86c9b3611abd775fea1eb2dbf5e69a7f35831fdd Mon Sep 17 00:00:00 2001 From: Zoey de Souza Pessanha Date: Thu, 18 Apr 2024 01:52:14 -0300 Subject: [PATCH] feat: live view and plug integrations --- .env.dev | 2 + config/config.exs | 4 + lib/supabase/go_true/live_view.ex | 129 ++++++++++++++ lib/supabase/go_true/plug.ex | 189 +++++++++++++++++---- lib/supabase/go_true/plug/verify_header.ex | 24 --- lib/supabase/go_true/user_handler.ex | 3 +- mix.exs | 1 + mix.lock | 7 + 8 files changed, 302 insertions(+), 57 deletions(-) create mode 100644 .env.dev create mode 100644 lib/supabase/go_true/live_view.ex delete mode 100644 lib/supabase/go_true/plug/verify_header.ex diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..2b1dd6d --- /dev/null +++ b/.env.dev @@ -0,0 +1,2 @@ +export SUPABASE_URL=http://127.0.0.1:54321 +export SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU diff --git a/config/config.exs b/config/config.exs index eb32525..8eec970 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,4 +4,8 @@ if config_env() == :dev do config :supabase_potion, supabase_base_url: System.get_env("SUPABASE_URL"), supabase_api_key: System.get_env("SUPABASE_KEY") + + config :supabase_gotrue, + authentication_client: :pescarte, + endpoint: nil end diff --git a/lib/supabase/go_true/live_view.ex b/lib/supabase/go_true/live_view.ex new file mode 100644 index 0000000..ab5f21a --- /dev/null +++ b/lib/supabase/go_true/live_view.ex @@ -0,0 +1,129 @@ +defmodule Supabase.GoTrue.LiveView do + @moduledoc """ + Provides LiveView integrations for the Supabase GoTrue authentication in Elixir applications. + + This module enables the seamless integration of authentication flows within Phoenix LiveView applications by leveraging the Supabase GoTrue SDK. It supports operations such as mounting current users, handling authenticated and unauthenticated states, and logging out users. + + ## Configuration + + The module requires some application environment variables to be set: + - `authentication_client`: The Supabase client used for authentication. + - `endpoint`: Your web app endpoint, used internally for broadcasting user disconnection events. + - `signed_in_path`: The route to where socket should be redirected to after authentication + + You can set up these config in your `config.exs`: + ``` + config :supabase_gotrue, + endpoint: YourApp.Endpoint, + signed_in_path: "/dashboard", + authentication_client: :my_supabase_potion_client_name + ``` + + ## Usage + + Typically, this module is used in your `YourAppWeb.Router`, to handle user authentication states through a series of `on_mount` callbacks, which ensure that user authentication logic is processed during the LiveView lifecycle. + + Check `on_mount/4` for more detailed usage instructions on LiveViews + """ + + import Phoenix.Component, only: [assign_new: 3] + + alias Phoenix.LiveView.Socket + alias Supabase.GoTrue + alias Supabase.GoTrue.Admin + alias Supabase.GoTrue.Session + alias Supabase.GoTrue.User + + @client Application.compile_env!(:supabase_gotrue, :authentication_client) + @endpoint Application.compile_env!(:supabase_gotrue, :endpoint) + @signed_in_path Application.compile_env(:supabase_gotrue, :signed_in_path) + + @doc """ + Logs out the user from the session and broadcasts a disconnect event. + + ## Parameters + - `socket`: The `Phoenix.LiveView.Socket` representing the current LiveView state. + - `scope`: An optional scope parameter for the logout request. Check `Supabase.GoTrue.Admin.sign_out/3` for more detailed information. + + ## Examples + + iex> log_out_user(socket, :local) + # Broadcasts 'disconnect' and removes the user session + """ + def log_out_user(%Socket{} = socket, scope) do + user = socket.assigns.current_user + user_token = socket.assigns[:user_token] + session = %Session{access_token: user_token} + user_token && Admin.sign_out(@client, session, scope) + @endpoint.broadcast_from(self(), socket.id, "disconnect", %{user: user}) + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + defmodule PescarteWeb.PageLive do + use PescarteWeb, :live_view + on_mount {PescarteWeb.Authentication, :mount_current_user} + ... + end + Or use the `live_session` of your router to invoke the on_mount callback: + live_session :authenticated, on_mount: [{PescarteWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(session, socket)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(session, socket) + + if socket.assigns.current_user do + {:cont, socket} + else + {:halt, Phoenix.LiveView.redirect(socket, to: @signed_in_path)} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(session, socket) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: @signed_in_path)} + else + {:cont, socket} + end + end + + def mount_current_user(session, socket) do + case session do + %{"user_token" => user_token} -> + socket + |> assign_new(:current_user, fn -> + session = %Session{access_token: user_token} + case GoTrue.get_user(@client, session) do + {:ok, %User{} = user} -> user + _ -> nil + end + end) + |> assign_new(:user_token, fn -> user_token end) + + %{} -> + assign_new(socket, :current_user, fn -> nil end) + end + end +end diff --git a/lib/supabase/go_true/plug.ex b/lib/supabase/go_true/plug.ex index 9e77836..04395e2 100644 --- a/lib/supabase/go_true/plug.ex +++ b/lib/supabase/go_true/plug.ex @@ -1,61 +1,188 @@ defmodule Supabase.GoTrue.Plug do - @moduledoc false + @moduledoc """ + Provides Plug-based authentication support for the Supabase GoTrue authentication in Elixir applications. + + This module offers a series of functions to manage user authentication through HTTP requests in Phoenix applications. It facilitates operations like logging in with a password, logging out users, fetching the current user from a session, and handling route protections based on authentication state. + + ## Configuration + + The module requires some application environment variables to be set: + - `authentication_client`: The Supabase client used for authentication. + - `signed_in_path`: The route to where conn should be redirected to after authentication + - `not_authenticated_path `: The route to where conn should be redirect to if user isn't authenticated + + You can set up these config in your `config.exs`: + ``` + config :supabase_gotrue, + endpoint: YourApp.Endpoint, + signed_in_path: "/dashboard", + authentication_client: :my_supabase_potion_client_name + ``` + + ## Authentication Flow + + It handles session management, cookie operations, and redirects based on user authentication status, providing a seamless integration for user sessions within Phoenix routes. + """ import Plug.Conn - import Supabase.Client, only: [is_client: 1] + import Phoenix.Controller - alias Plug.Conn + alias Supabase.GoTrue + alias Supabase.GoTrue.Admin + alias Supabase.GoTrue.Session + alias Supabase.GoTrue.User - @key "supabase_gotrue_token" + @session_cookie "_supabase_go_true_session_cookie" + @session_cookie_options [sign: true, same_site: "Lax"] - def session_active?(%Conn{} = conn) do - key = :second |> System.os_time() |> to_string() - get_session(conn, key) == nil - rescue - ArgumentError -> false - end + @client Application.compile_env!(:supabase_gotrue, :authentication_client) + @signed_in_path Application.compile_env(:supabase_gotrue, :signed_in_path) + @not_authenticated_path Application.compile_env(:supabase_gotrue, :not_authenticated_path, "/") - def authenticated?(%Conn{} = conn) do - not is_nil(conn.private[@key]) - end + @doc """ + Logs in a user using a username and password. Stores the user token in the session and a cookie, if a `"remember_me"` key is present inside `params`. + + For more information on how Supabase login with email and password works, check `Supabase.GoTrue.sign_in_with_password/2` + """ + def log_in_with_password(conn, params \\ %{}) do + {:ok, session} = GoTrue.sign_in_with_password(@client, params) + user_return_to = get_session(conn, :user_return_to) - def put_current_token(%Conn{} = conn, token) do - put_private(conn, @key, token) + conn + |> renew_session() + |> put_token_in_session(session.access_token) + |> maybe_write_session_cookie(session, params) + |> redirect(to: user_return_to || @signed_in_path) end - def put_session_token(%Conn{} = conn, token) do + defp renew_session(conn) do conn - |> put_session(@key, token) |> configure_session(renew: true) + |> clear_session() end - def sign_in(%Conn{} = conn, client, attrs) when is_client(client) do - case maybe_sign_in(conn, client, attrs) do - {:ok, session} -> put_session_token(conn, session.access_token) + defp maybe_write_session_cookie(conn, %Session{} = session, params) do + case params do + %{"remember_me" => "true"} -> + token = session.access_token + opts = Keyword.put(@session_cookie_options, :max_age, session.expires_in) + put_resp_cookie(conn, @session_cookie, token, opts) _ -> conn end end - defp maybe_sign_in(conn, client, credentials) do - if session_active?(conn) do - Supabase.GoTrue.sign_in_with_password(client, credentials) + @doc """ + Logs out the user from the application, clearing session data + """ + def log_out_user(%Plug.Conn{} = conn, scope) do + user_token = get_session(conn, :user_token) + session = %Session{access_token: user_token} + user_token && Admin.sign_out(@client, session, scope) + + conn + |> renew_session() + |> redirect(to: @not_authenticated_path ) + end + + + @doc """ + Retrieves the current user from the session or a signed cookie, assigning it to the connection's assigns. + + Can be easily used as a plug, for example inside a Phoenix web app + pipeline in your `YourAppWeb.Router`, you can do something like: + ``` + import Supabase.GoTrue.Plug + + pipeline :browser do + plug :fetch_session # comes from Plug.Conn + plug :fetch_current_user + # rest of plug chain... + end + ``` + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && fetch_user_from_session_token(user_token) + assign(conn, :current_user, user) + end + + defp fetch_user_from_session_token(user_token) do + case GoTrue.get_user(@client, %Session{access_token: user_token}) do + {:ok, %User{} = user} -> user + _ -> nil end end - def sign_out(%Conn{} = conn) do - if session_active?(conn) do - delete_session(conn, @key) + defp ensure_user_token(conn) do + if user_token = get_session(conn, :user_token) do + {user_token, conn} else + conn = fetch_cookies(conn, signed: [@session_cookie]) + + if user_token = conn.cookies[@session_cookie] do + {user_token, put_token_in_session(conn, user_token)} + else + {nil, conn} + end + end + end + + @doc """ + Redirects an user to the configured `signed_in_path` if it is authenticated, if not, just halts the connection. + + Generaly you wan to use it inside your scopes routes inside `YourAppWeb.Router`: + ``` + scope "/" do + pipe_trough [:browser, :redirect_if_user_is_authenticated] + + get "/login", LoginController, :login + end + ``` + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: @signed_in_path) + |> halt() + else + conn + end + end + + @doc """ + Ensures an user is authenticated before executing the rest of Plugs chain. + + Generaly you wan to use it inside your scopes routes inside `YourAppWeb.Router`: + ``` + scope "/" do + pipe_trough [:browser, :require_authenticated_user] + + get "/super-secret", SuperSecretController, :secret + end + ``` + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do conn + else + conn + |> maybe_store_return_to() + |> redirect(to: @signed_in_path) + |> halt() end end - def fetch_token_from_cookies(%Conn{} = conn) do - token = conn.req_cookies[@key] || conn.req_cookies[to_string(@key)] - if token, do: {:ok, token}, else: {:error, :not_found} + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) end - def current_token(%Conn{} = conn) do - conn.private[@key] + defp maybe_store_return_to(conn), do: conn + + defp put_token_in_session(conn, token) do + base64_token = Base.url_encode64(token) + + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_session:#{base64_token}") end end diff --git a/lib/supabase/go_true/plug/verify_header.ex b/lib/supabase/go_true/plug/verify_header.ex deleted file mode 100644 index 0ce426e..0000000 --- a/lib/supabase/go_true/plug/verify_header.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Supabase.GoTrue.Plug.VerifyHeader do - @moduledoc false - - import Plug.Conn - - alias Supabase.GoTrue - - @behaviour Plug - - @impl true - def init(opts \\ []), do: opts - - @impl true - def call(%Plug.Conn{} = conn, _opts) do - if GoTrue.Plug.current_token(conn) do - conn - else - case get_req_header(conn, :authorization) do - ["Bearer " <> token] -> GoTrue.Plug.put_current_token(conn, token) - _ -> halt(conn) - end - end - end -end diff --git a/lib/supabase/go_true/user_handler.ex b/lib/supabase/go_true/user_handler.ex index 0aa86cd..abb62e9 100644 --- a/lib/supabase/go_true/user_handler.ex +++ b/lib/supabase/go_true/user_handler.ex @@ -103,12 +103,11 @@ defmodule Supabase.GoTrue.UserHandler do defp sign_in_request(%Client{} = client, %SignInRequest{} = request, grant_type) when grant_type in @grant_types do - query = URI.encode_query(%{grant_type: grant_type, redirect_to: request.redirect_to}) headers = Fetcher.apply_client_headers(client) client |> Client.retrieve_auth_url(@sign_in_uri) - |> append_query(query) + |> append_query(%{grant_type: grant_type, redirect_to: request.redirect_to}) |> Fetcher.post(request, headers) end diff --git a/mix.exs b/mix.exs index 0c5ad5f..33455b6 100644 --- a/mix.exs +++ b/mix.exs @@ -29,6 +29,7 @@ defmodule SupabaseAuth.MixProject do [ {:plug, "~> 1.15"}, {:supabase_potion, "~> 0.3"}, + {:phoenix_live_view, "~> 0.20"}, {:ex_doc, ">= 0.0.0", runtime: false} ] end diff --git a/mix.lock b/mix.lock index 511401b..1984dee 100644 --- a/mix.lock +++ b/mix.lock @@ -20,8 +20,15 @@ "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "supabase_potion": {:hex, :supabase_potion, "0.3.0", "9c63eda160d5eaece75c04caf892dbfa9ab19065deff6ce0eca81301f69f44e1", [:mix], [{:ecto, "~> 3.10", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_doc, ">= 0.0.0", [hex: :ex_doc, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "456d3f3238a1eb959981beaa979270e9d71a01da3fc47d99e5a745a290bc9ea8"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, }