Example of best way to hydrate sessions for LiveView

Going off the example auth blog post where we have a plug helper like so.

# lib/my_app/router.ex
import MyAppWeb.UserAuth

pipeline :browser do
   ...
  plug :fetch_current_user
end

# lib/my_app_web/controllers/user_auth.ex
@doc """
Authenticates the user by looking into the session
and remember me token.
"""
def fetch_current_user(conn, _opts) do
  {user_token, conn} = ensure_user_token(conn)
  user = user_token && Accounts.get_user_by_session_token(user_token)
  assign(conn, :current_user, user)
end

In the context of a LiveView module, I see the user_token in the session on the mount callback.

I have seen the notes about sessions being serialized into strings, should I be concerned about upstream pipelines that adds this usrer_token to the session? When inspecting my session values on the mount I see a binary for the user token.

"user_token" => <<..,, ..., ..., ..., >>

Also, I’ve seen the notes about storing the user id vs the whole user value because of the string serialization.

After some reading, it looks like the past way of setting up the session from the route no longer works. https://github.com/phoenixframework/phoenix_live_view/commit/0020c35a438fb9da0d26121cfe9da45e6813a486

My other question is do I just make another plug that sets the session with the user_id for the socket?
if so I assume I then will need to query for the user via the user id every request for LiveView and auth. Is there a better way?

1 Like

That’s now the default behaviour (all the session is passed and you don’t need to specify the keys):

When a LiveView is rendered, all of the data currently stored in the connection session (see Plug.Conn.get_session/1 ) will be given to the LiveView.

It is also possible to pass additional session information to the LiveView through a session parameter:

# In the router
live "/thermostat", ThermostatLive, session: %{"extra_token" => "foo"}

# In a view
<%= live_render(@conn, AppWeb.ThermostatLive, session: %{"extra_token" => "foo"}) %>

Notice the :session uses string keys as a reminder that session data is serialized and sent to the client. So you should always keep the data in the session to a minimum. I.e. instead of storing a User struct, you should store the “user_id” and load the User when the LiveView mounts.

You could always add a cache for getting the user with a low refresh ttl value.

2 Likes

Hello,
I wanted to achieve the same kind of behaviour and be able to access to the current_user in my live view assigns.
The best would be able to assign the current_user to the socket assign, but it doesn’t seems to be possible.
The only way I found to achieve this is to inject a mount function, in live :

  def live do
    quote do
      use BrandManagerWeb.LoadUser
      use Phoenix.LiveView, layout: {BrandManagerWeb.LayoutView, "app.html"}
      alias BrandManagerWeb.Router.Helpers, as: Routes
    end
  end
defmodule BrandManagerWeb.LoadUser do
  alias Core.Accounts
  alias Phoenix.LiveView

  defmacro __using__(_opts) do
    quote do
      def mount(params, %{"user_id" => user_id} = session, socket) do
        session = Map.delete(session, "user_id")

        socket =
          LiveView.assign_new(socket, :current_user, fn ->
            manager_id && Accounts.get_user(user_id)
          end)

        mount(params, session, socket)
      end
    end
  end
end

It is working, but it is kind of a hack :wink:

Is there a better way to achieve this ?
Thanks in advance!

3 Likes

It’s what the docs suggest, so definitely not a hack (well, minus the fact that you are doing it in a macro, but other than that it’s the same thing).

1 Like

I’m mostly asking in the context of this post https://dashbit.co/blog/a-new-authentication-solution-for-phoenix

So I already have a pipeline that gets a user from a give user token found in the session.


At this point, my first question would be: Should I make a different pipeline for Controllers vs LiveViews?
If I’m already doing a query to look the user up and put that user whole in my assigns via current_user does this still make sense to do, only to add another plug that plucks the user’s ID only just to have to do it all over again on the call to mount?

Seems to me that I would want a different pipeline that calls fetch_current_user_id for LiveView and fetch_current_user for normal controllers. That way I forgo the query for the user in my LiveView till later in the mount where it needs to happen again anyways.

That seems to only be true for the first call to mount as the user_token from my session only seems to be available at that time and not the following call to mount.

You need to pull the user_token from the session and fetch the user in the same way the plug pipeline does. You can make use of assign_new to avoid fetching on the HTTP request, but you need to fallback to fetching on the connected mount, using the same mechanism that the plug pipeline does:

      def mount(_params, %{"user_token" => user_token}, socket) do
        {:ok, 
          socket 
          |> ...
          |> assign_new(:current_user, fn -> 
            Accounts.get_user_by_session_token(user_token)
         end)}
      end

To avoid duplication in your LVs, you could pull this out into an assign_defaults function that you write to wire up common assigns, like current_user. Make sense?

4 Likes

Still having a little trouble understanding the correct flow.

As stated before I’m working with the same pipeline as the post about auth.

So starting from there my routes look like so.

pipeline :browser do
  ...
  plug :fetch_current_user
end

scope "/", MyAppWeb do
  pipe_through :browser
  
  ...
  live "/posts/:slug", PostLive.Show, :show
end

And I have my view like so.

# /lib/my_app_web/live/post_live/show.ex

def mount(params, %{"user_token" => user_token} = session, socket) do
  # shows user token in the session
  IO.inspect([params, session, socket])

  {:ok,
    assign_new(socket, :current_user, fn ->
      {:ok, user} = MyApp.Accounts.get_user_by_session_token(user_token)
      user
    end)
    # Shows current user in the assigns
    |> IO.inspect()}
end

# Runs the second time for the js call
def mount(params, session, socket) do
  # Does not show user_token or current_user
  IO.inspect([params, session, socket])

  {:ok, socket}
end

I can see from my logs that the first call to mount for the HTTP request looks like so.
I can also see from the IO.inspect from the first call to mount which includes a user_token in the session.

[info] GET /posts/last
[debug] Processing with Phoenix.LiveView.Plug.show/2
  Parameters: %{"slug" => "foobar"}
  Pipelines: [:browser]
[debug] QUERY OK source="users_tokens" db=9.2ms queue=0.1ms idle=7128.2ms
SELECT u1."id", u1."email", u1."hashed_password", u1."confirmed_at", u1."admin", u1."inserted_at", u1."updated_at" FROM "users_tokens" AS u0 INNER JOIN "users" AS u1 ON u1."id" = u0."user_id" WHERE (...)]
[
  %{"slug" => "foobar"},
  %{
    "_csrf_token" => "...",
    "user_token" => ...
  },
  #Phoenix.LiveView.Socket<
    assigns: %{
      flash: %{},
      live_action: :show,
      live_module: MyAppWeb.PostLive.Show
    },
    ...
   >
]

I can also see the IO.inspect at the end of the same pipeline I used to assign_new which shows the current_user being set in the assigns as I would expect.

#Phoenix.LiveView.Socket<
  assigns: %{
    current_user: #MyApp.Accounts.User<
      ...
    >,
    ...
  },
  changed: %{current_user: true},
  ...
>

But when it comes to the second call to mount this is where I get a little lost.
Looking at my logs I see the call to js.

[info] CONNECTED TO Phoenix.LiveView.Socket in 326µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "...", "vsn" => "2.0.0"}
[
  %{"slug" => "foobar"},
  %{},
  #Phoenix.LiveView.Socket<
    assigns: %{
      flash: %{},
      live_action: :show,
      live_module: MyAppWeb.PostLive.Show
    },
    changed: %{},
    ...
  >
]

Here I don’t see the session nor the prior assigns of current_user.

Also I would have also assumed I should not have directly put the whole current_user into the assigns but rather thought I would need to reconcile the user via user_id.

your second greedy match mount/3 should not be necessary and the fact that you are getting there is definitely the issue. The connected call will include the Plug session , provided you have wired up the session options in your endpoint and csrf token in app.js. Can you post your endpoint? If you are getting here:

# Runs the second time for the js call
def mount(params, session, socket) do
  # Does not show user_token or current_user
  IO.inspect([params, session, socket])

  {:ok, socket}
end

Then it makes sense the current_user is not there, because it was never set. The first clause should have matched, so let’s kill the catch-all and figure out why your plug session isn’t being provided.

2 Likes

Heres my endpoint.

And my js

Also note this is on my throw away branch wip just incase you try to explore other parts of this repo.

I am seeing a discrepancy between the _csrf_token from the first and second call to the mount. I assume they should be the same for each?

Edit: Ok I think I found the issue in my endpoint.
I was missing websocket: [connect_info: [session: @session_options]]

Edit #2:
Yeah that was it looks like I’m seeing the user_token in the second call to mount now. :+1:

2 Likes