After collecting information from multiple sources (this forum, blogs, StackOverflow and GitHub), I was finally able to successfully embed part of an existing Phoenix LiveView app in an iframe.
I’d like to share the steps here, both for future reference and to hopefully help others. If you find this useful, please let me know. Do you think the official docs should contain a small guide on this topic?
Configuring a LiveView app to be embeddable via iframe
There are luckily only a few changes to be made to a freshly generated app. There are some tradeoffs and considerations to be made, and your requirements might differ from the decisions below.
This guide assumes you only need to expose part of your application, rendering unauthenticated pages (not depending on session cookies), and you want to keep the standard security measures in place.
There are only two parts, let’s begin.
LiveView Socket (endpoint.ex and app.js)
Some threads have suggested changing the default session cookie config from same_site: "Lax"
to same_site: "None", secure: true
, but that affects all LiveViews and other routes.
Instead, following a suggestion from Chris McCord, use a separate socket for iframe-originated WebSocket connections (and long poll fallback):
# file: my_app/lib/my_app_web/endpoint.ex
# Default config:
@session_options [
store: :cookie,
key: "_gpt_demo_key",
signing_salt: "ZfIfSPDp",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Add new socket specifically for embedded pages. This socket will not
# Have access to session info, in particular won't be able to read information
# stored in session cookies such as CSRF token and logged in user token.
socket "/embed/live", Phoenix.LiveView.Socket, websocket: true, longpoll: true
Update my_app/assets/js/app.js
to connect to the correct URL:
let socketUrl = window.location.pathname.startsWith("/embed/") ? "/embed/live" : "/live"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket(socketUrl, Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
Pipeline, layouts, routes (router.ex)
Create a new Plug pipeline for the routes that should be embeddable. Possibly not all pages of your app should be allowed within an iframe.
As we’re not passing session information to LiveView as per our Socket config earlier, make sure the root_layout
and layout
in use don’t depend on session information, for instance they do not refer to @current_user
if you use mix phx.gen.auth
.
You may want to create specific layouts for the embed pages, which might reuse components from the rest of the app.
The layout can be configured in the live_session
declaration in router.ex
(Live layouts — Phoenix LiveView v0.20.17).
Example, assuming you created a layout my_app/lib/my_app_web/components/layouts/embedded.html.heex
:
# file: my_app/lib/my_app_web/router.ex
# Default config:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
# Similar to default `:browser` pipeline, but with one more plug
# `:allow_iframe` to securely allow embedding in an iframe.
pipeline :embedded do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :allow_iframe
end
# Configure LiveView routes using the `:embedded` pipeline
# and custom `embedded.html.heex` layout.
scope "/embed", MyAppWeb do
pipe_through [:embedded]
live_session :embedded,
layout: {MyAppWeb.Layouts, :embedded} do
live "/:id", EmbeddedLive
end
end
# A plug to set a CSP allowing embedding only on certain domains.
# This is just an example, actual implementation depends on project
# requirements.
defp allow_iframe(conn, _opts) do
conn
|> delete_resp_header("x-frame-options")
|> put_resp_header(
"content-security-policy",
"frame-ancestors 'self' https://example.com" # Add your list of allowed domain(s) here
)
end
For handling only GET
requests, it is fine to keep the :protect_from_forgery
plug. Other use cases, like requiring form submissions, login, etc, might require further changes.
After trying a few config changes, debugging infinitely reloading pages, and so on, the above is the minimal set of changes that got me to a working state.
If you got here, thanks for reading and hope it was helpful to you, happy coding