LiveView with OAuth - Prevent Automatic Login After Logging Out in Another Tab

I’m working on an application that will use Okta for authentication. I used phx.gen_auth as a starting place and then used this blog post for reference in adding OAuth using the Ueberauth library with the Okta strategy.

Then I added a few more tweaks so that unauthenticated users would be routed directly to the Okta sign-in page instead of going to the generated Log In page.

I have the LiveView protected by the :require_authenticated_user plug in the router, and I also have an on_mount hook that uses the Accounts.get_user_by_session_token(user_token) method to check for an authenticated user and redirects to the login page if there isn’t a user logged in.

Everything is working great with getting the user logged in, but things get a little strange on log out when I have more than one tab open in the same browser, and both tabs are on the page with the protected LiveView.

On the tab where I click “Log out,” I’m redirected back to the home page with the flash message telling my that my log out was successful. In the database, I can see that the session token has been deleted.

In the second tab - the disconnect event is fired, and the server disconnects the web socket as expected. Then the client-side JavaScript notices the socket has been disconnected and tries to reconnect - also as expected. This kicks off the authentication process again, and because I’m still logged into Okta - I get automatically logged back in on the second tab with a fresh token in the database.

I think everything is working as it’s meant to work, but as a user - it feels a little uncomfortable. I would prefer to be logged out of all tabs until I take some action to log back in.

Would it make sense to use a JS hook to listen for the disconnect event and redirect to an unauthenticated page? I think that would happen on a disconnect for any reason, which isn’t quite what I want either - but maybe broadcasting a more specific event that I could listen to from a JS hook?

Or is there another approach that would be better?

Yup, you should use a custom event. Here’s how I lock out the user from all the devices:

You can broadcast an event like execute_order_66 to user:user_id topic.


Handle the event fired:

def live_view do
  quote do
    use Phoenix.LiveView,
      layout: {DerpyCoderWeb.LayoutView, "live.html"}

    # Handle the lock account event!
    def handle_info({:lock_account, _}, socket) do
      {:noreply, socket |> redirect(to: "/")}
    end

    unquote(view_helpers())
  end
end

accounts.ex

# ==============================================================================
# Used to lock user out, across all devices!!
# ==============================================================================
@spec lock(map()) :: {:ok, map()} | {:error, map()}
def lock(%User{id: id, role: :super_admin}), do: not is_super_admin?(id)
 def lock(user) do
  user
  |> User.lock_changeset()
  |> Repo.update()
  |> broadcast(:lock_account)
end

# TODO: Add another broadcast for logging user out of all devices.
def subscribe(user) do
  Phoenix.PubSub.subscribe(DerpyCoder.PubSub, topic(user))
end

defp broadcast({:ok, user}, event) do
  Phoenix.PubSub.broadcast(
    DerpyCoder.PubSub,
    topic(user),
    {event, user}
  )
   {:ok, user}
end

defp broadcast({:error, _changeset} = error, _event), do: error

defp topic(%User{id: id}) do
  "user:#{id}"
end

router.ex

# ==============================================================================
# Below routes have custom root layout
# ==============================================================================
live_session :user_dashboard,
  on_mount: {DerpyCoderWeb.Permit, :any_user},
  root_layout: {DerpyCoderWeb.LayoutView, "user_dashboard.html"} do
  scope "/users", DerpyCoderWeb do
    pipe_through [:browser, :any_user]

    live "/dashboard", UserDashboardLive, :index
  end
end

permit.ex

def on_mount(:any_user, _params, session, socket) do
  {:cont, socket}
  |> assign_user(session)
  |> verify_user()
  |> verify_lock()
  |> verify_email()
  |> subscribe_user()
end

# ==============================================================================
# Subscribe to user_centric topic, for broadcasts, like:
# Notifications, Account Lock, etc.
# ==============================================================================
defp subscribe_user({:cont, socket}) do
  current_user = socket.assigns.current_user

  if connected?(socket), do: Accounts.subscribe(current_user)

  {:cont, socket}
end

defp subscribe_user({:halt, _} = arg), do: arg

live_helpers.ex

# ==============================================================================
# Verify that user's Account isn't locked.
# ==============================================================================
def verify_lock({:cont, socket}) do
  current_user = socket.assigns.current_user
  if current_user.locked_at && current_user.role != :super_admin &&
       not DerpyCoder.Accounts.is_super_admin?(current_user.id) do
    {:halt, socket |> kick_locked_user_out()}
  else
    {:cont, socket}
  end
end

def verify_lock({:halt, _} = arg), do: arg

P.S. The above approach broadcasts across all connection for that user. Since we are broadcasting over a topic like user:user_id, but if we restrict the broadcast to a topic like live_socket_id, then only the tabs open in a specific browser will receive that event.

if live_socket_id = get_session(conn, :live_socket_id) do
  DerpyCoderWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end

So maybe come up with a name-spacing approach for the topics.

1 Like

Thanks for the detailed example and explanation. I will give this a try!

Why are users still logged in after logging out? Imo that’s your issue here, that you’d want to figure out, not hacks around the root cause.

1 Like

I think what you’re looking for is the “Single Logout” flow provided by SAML - here’s Okta’s help page about setting it up.

You’d implement this by sending the SLO request before removing the session token.

2 Likes