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:
- My app returns
application/liquid
(Liquid is Shopify’s templating language) instead oftext/html
, which Shopify re-renders and embeds directly into the site theme (in order to share styles, common nav, etc.). - 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 includingsignature
(so that I can verify the authenticity of the proxied request) andlogged_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:
- The
MyApp.AuthToken
module referenced is a pretty thin wrapper around an encryptedPhoenix.Token
. - 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). - 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.