Authenticating LiveView cross-domain using tokens; no cookies

Hi friends!

I’ve been working with LiveView in a somewhat unusual context and wanted to both share the solution I’m currently using and solicit feedback/ideas on how it could be improved.

Background & context

I’m running a LiveView app embedded in a Shopify store using Shopify integration called app proxies. My app registers a path on a Shopify store, e.g. https://example-store.com/a/my-app, and any requests to that path (or further nested paths) are directed to my registered endpoint, e.g. https://my-app.com/a/my-app.

There are two catches:

  1. My app returns application/liquid (Liquid is Shopify’s templating language) instead of text/html, which Shopify re-renders and embeds directly into the site theme (in order to share styles, common nav, etc.).
  2. For security reasons, Shopify’s proxy strips numerous headers, including Cookie, Set-Cookie, and the like. However, the proxied requests include additional query parameters from Shopify including signature (so that I can verify the authenticity of the proxied request) and logged_in_user_id, so I know which customer is browsing.

Given the context above, the goal is to securely enable a LiveView application running embedded in this page to connect directly to my server and authenticate the current user.

For now, I’ve added the additional constraint that this is only for logged in users, i.e. a logged_in_user_id is passed in by Shopify; otherwise, the client is redirected to log in.

Current solution

Because I don’t have access to a cookie session, I chose to strip out the default CSRF protections in favor of a Phoenix.Token-based solution. The live socket is mounted like so in endpoint.ex:

socket "/app-proxy/live",
       Phoenix.LiveView.Socket,
       websocket: [
         connect_info: [:peer_data, :x_headers, :user_agent, :uri],
         check_origin: {__MODULE__, :allowed_app_proxy_origin?, []}
       ]

The general strategy is to generate an encrypted, expiring Phoenix.Token containing the user’s ID and any additional metadata, embed it in the rendered page, and then authenticate using that token during the connected mount. This is in contrast to the usual way of doing things, which would be to store the user ID in the cookie session and authenticate using that during live mount.

Here’s an outline of how this comes together:

# in router.ex

live_session :app_proxy, on_mount: MyAppWeb.AppProxy.LiveAuth do
  live "/", WhateverLive
end

The auth module:

defmodule MyAppWeb.AppProxy.LiveAuth do
  import Phoenix.LiveView
  import Phoenix.Component # or Phoenix.LiveView.Helpers

  alias MyApp.AuthToken
  alias MyApp.Customers

  @doc """
  Generate an encrypted auth token for the currently logged in customer.
  """
  def token(%{assigns: %{current_customer: customer}} = conn) do
    AuthToken.create({customer.id, meta_from_conn(conn)})
  end

  @doc false
  def on_mount(:default, params, _session, socket) do
    case fetch_customer(socket, params) do
      {:ok, customer} ->
        {:cont, assign(socket, :customer, customer)}

      _error ->
        {:halt, redirect(socket, to: "/")}
    end
  end

  def fetch_customer(socket, params) do
    if connected?(socket) do
      fetch_customer_from_token(socket)
    else
      fetch_customer_from_params(params)
    end
  end
 
  defp fetch_customer_from_params(%{"logged_in_customer_id" => id}), do: Customers.fetch(id)
  defp fetch_customer_from_params(_), do: :error

  defp fetch_customer_from_token(socket) do
    with %{"token" => token} <- get_connect_params(socket),
         {:ok, {id, meta}} <- AuthToken.validate(token),
         ^meta <- meta_from_socket(socket) do
      Customers.fetch(id)
    else
      _ -> :error
    end
  end

  defp meta_from_conn(conn) do
    # extract remote_ip, user_agent, anything else we may want to verify
  end

  defp meta_from_socket(socket) do
    # extract same data as meta_from_conn/1
  end
end

As mentioned before, this token would then be embedded in the view and used to start the socket:

<!-- app_proxy_layout.html.heex -->

<script>
  MyAppJS.startLiveSocket({
    token: <%= raw Jason.encode!(MyAppWeb.AppProxy.LiveAuth.token(@conn)) %>
  })
</script>

In app.js, the live socket connection is then wrapped in a function:

const startLiveSocket = (token) => {
  if (window.liveSocket) return

  const liveSocket = new LiveSocket(socketUrl, Socket, { params: { token } })
  liveSocket.connect()
  window.liveSocket = liveSocket
}

A few more things about the above:

  1. The MyApp.AuthToken module referenced is a pretty thin wrapper around an encrypted Phoenix.Token.
  2. The token expires. The client knows the expiration and can issue a JavaScript fetch for a new token (made through the Shopify proxy so it’s an authenticated request).
  3. I’m requiring the IP and user agent used to generate the token during dead render match the IP and user agent sent with the websocket connection request. I believe this should help mitigate any issues of stolen tokens, as the attacker would need to be able to issue requests from the same IP. (This would be possible if untrusted JS was running in the user’s client, though.)

What can be better?

I think that, given the relative low-risk nature of this particular project, I’m happy with the above. But I would still love to hear alternatives or feedback if folks have it.