diff --git a/assets/js/app.js b/assets/js/app.js
index 64b078032..5695e3fa7 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -14,6 +14,7 @@ import { LiveSocket } from "phoenix_live_view";
import hooks from "./hooks";
import { morphdomOptions } from "./dom";
import { loadUserData } from "./lib/user";
+import { loadAppAuthToken } from "./lib/app";
import { settingsStore } from "./lib/settings";
import { registerTopbar, registerGlobalEventHandlers } from "./events";
@@ -30,6 +31,7 @@ const liveSocket = new LiveSocket(
_csrf_token: csrfToken,
// Pass the most recent user data to the LiveView in `connect_params`
user_data: loadUserData(),
+ app_auth_token: loadAppAuthToken(),
};
},
hooks: hooks,
diff --git a/assets/js/hooks/app_auth.js b/assets/js/hooks/app_auth.js
new file mode 100644
index 000000000..dbe1296e7
--- /dev/null
+++ b/assets/js/hooks/app_auth.js
@@ -0,0 +1,15 @@
+import { storeAppAuthToken } from "../lib/app";
+
+/**
+ * A hook for the app auth page.
+ */
+const AppAuth = {
+ mounted() {
+ this.handleEvent("persist_app_auth", ({ slug, token }) => {
+ storeAppAuthToken(slug, token);
+ this.pushEvent("app_auth_persisted");
+ });
+ },
+};
+
+export default AppAuth;
diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js
index b53922226..859ffc492 100644
--- a/assets/js/hooks/index.js
+++ b/assets/js/hooks/index.js
@@ -1,3 +1,4 @@
+import AppAuth from "./app_auth";
import AudioInput from "./audio_input";
import Cell from "./cell";
import CellEditor from "./cell_editor";
@@ -21,6 +22,7 @@ import UserForm from "./user_form";
import VirtualizedLines from "./virtualized_lines";
export default {
+ AppAuth,
AudioInput,
Cell,
CellEditor,
diff --git a/assets/js/hooks/js_view.js b/assets/js/hooks/js_view.js
index abf3a6fff..96053e9d4 100644
--- a/assets/js/hooks/js_view.js
+++ b/assets/js/hooks/js_view.js
@@ -46,11 +46,11 @@ import { initializeIframeSource } from "./js_view/iframe";
* * `data-js-path` - a relative path for the initial view-specific
* JS module
*
- * * `data-session-token` - token is sent in the "connect" message
- * to the channel
+ * * `data-session-token` - a session-specific token passed when
+ * joining the JS view channel
*
- * * `data-session-id` - the identifier of the session that this
- * view belongs go
+ * * `data-connect-token` - a JS view specific token passed in the
+ * "connect" message to the channel
*
* * `data-iframe-local-port` - the local port where the iframe is
* served
@@ -75,7 +75,7 @@ const JSView = {
this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000);
- this.channel = getChannel(this.props.sessionId, this.props.clientId);
+ this.channel = getChannel(this.props.sessionToken);
this.iframeActions = this.createIframe();
@@ -139,7 +139,7 @@ const JSView = {
this.channel.push(
"connect",
{
- session_token: this.props.sessionToken,
+ connect_token: this.props.connectToken,
ref: this.props.ref,
id: this.id,
},
@@ -175,8 +175,7 @@ const JSView = {
assetsBasePath: getAttributeOrThrow(this.el, "data-assets-base-path"),
jsPath: getAttributeOrThrow(this.el, "data-js-path"),
sessionToken: getAttributeOrThrow(this.el, "data-session-token"),
- sessionId: getAttributeOrThrow(this.el, "data-session-id"),
- clientId: getAttributeOrThrow(this.el, "data-client-id"),
+ connectToken: getAttributeOrThrow(this.el, "data-connect-token"),
iframePort: getAttributeOrThrow(
this.el,
"data-iframe-local-port",
diff --git a/assets/js/hooks/js_view/channel.js b/assets/js/hooks/js_view/channel.js
index cb33ecff3..3f5e80d70 100644
--- a/assets/js/hooks/js_view/channel.js
+++ b/assets/js/hooks/js_view/channel.js
@@ -13,13 +13,10 @@ let channel = null;
/**
* Returns channel used for all JS views in the current session.
*/
-export function getChannel(sessionId, clientId) {
+export function getChannel(sessionToken) {
if (!channel) {
socket.connect();
- channel = socket.channel("js_view", {
- session_id: sessionId,
- client_id: clientId,
- });
+ channel = socket.channel("js_view", { session_token: sessionToken });
channel.join();
}
diff --git a/assets/js/lib/app.js b/assets/js/lib/app.js
new file mode 100644
index 000000000..5fafd42c4
--- /dev/null
+++ b/assets/js/lib/app.js
@@ -0,0 +1,22 @@
+import { load, store } from "./storage";
+
+const APP_AUTH_TOKEN_PREFIX = "app_auth_token:";
+
+export function storeAppAuthToken(slug, token) {
+ store(APP_AUTH_TOKEN_PREFIX + slug, token);
+}
+
+export function loadAppAuthToken() {
+ const path = window.location.pathname;
+
+ if (path.startsWith("/apps/")) {
+ const slug = path.split("/")[2];
+ const token = load(APP_AUTH_TOKEN_PREFIX + slug);
+
+ if (token) {
+ return token;
+ }
+ }
+
+ return null;
+}
diff --git a/assets/package-lock.json b/assets/package-lock.json
index 5e54b57fc..2fd712546 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -60,10 +60,10 @@
"license": "MIT"
},
"../deps/phoenix_html": {
- "version": "3.2.0"
+ "version": "3.3.0"
},
"../deps/phoenix_live_view": {
- "version": "0.18.3",
+ "version": "0.18.15",
"license": "MIT"
},
"node_modules/@ampproject/remapping": {
diff --git a/lib/livebook/apps.ex b/lib/livebook/apps.ex
index 2316e20c8..b953ab8f5 100644
--- a/lib/livebook/apps.ex
+++ b/lib/livebook/apps.ex
@@ -53,5 +53,20 @@ defmodule Livebook.Apps do
end
end
+ @doc """
+ Looks up app session with the given slug and returns its settings.
+ """
+ @spec fetch_settings_by_slug(String.t()) :: {:ok, Livebook.Notebook.AppSettings.t()} | :error
+ def fetch_settings_by_slug(slug) do
+ case :global.whereis_name(name(slug)) do
+ :undefined ->
+ :error
+
+ pid ->
+ app_settings = Session.get_app_settings(pid)
+ {:ok, app_settings}
+ end
+ end
+
defp name(slug), do: {:app, slug}
end
diff --git a/lib/livebook/notebook/app_settings.ex b/lib/livebook/notebook/app_settings.ex
index 0ecb92ec9..aeec2f1b8 100644
--- a/lib/livebook/notebook/app_settings.ex
+++ b/lib/livebook/notebook/app_settings.ex
@@ -3,15 +3,21 @@ defmodule Livebook.Notebook.AppSettings do
use Ecto.Schema
- import Ecto.Changeset, except: [change: 1]
+ import Ecto.Changeset, except: [change: 1, change: 2]
@type t :: %__MODULE__{
- slug: String.t() | nil
+ slug: String.t() | nil,
+ access_type: access_type(),
+ password: String.t() | nil
}
+ @type access_type :: :public | :protected
+
@primary_key false
embedded_schema do
field :slug, :string
+ field :access_type, Ecto.Enum, values: [:public, :protected]
+ field :password, :string
end
@doc """
@@ -19,7 +25,11 @@ defmodule Livebook.Notebook.AppSettings do
"""
@spec new() :: t()
def new() do
- %__MODULE__{slug: nil}
+ %__MODULE__{slug: nil, access_type: :protected, password: generate_password()}
+ end
+
+ defp generate_password() do
+ :crypto.strong_rand_bytes(10) |> Base.encode32(case: :lower)
end
@doc """
@@ -27,9 +37,7 @@ defmodule Livebook.Notebook.AppSettings do
"""
@spec change(t(), map()) :: Ecto.Changeset.t()
def change(%__MODULE__{} = settings, attrs \\ %{}) do
- settings
- |> changeset(attrs)
- |> Map.put(:action, :validate)
+ changeset(settings, attrs)
end
@doc """
@@ -43,11 +51,25 @@ defmodule Livebook.Notebook.AppSettings do
defp changeset(settings, attrs) do
settings
- |> cast(attrs, [:slug])
- |> validate_required([:slug])
+ |> cast(attrs, [:slug, :access_type])
+ |> validate_required([:slug, :access_type])
|> validate_format(:slug, ~r/^[a-zA-Z0-9-]+$/,
message: "slug can only contain alphanumeric characters and dashes"
)
+ |> cast_access_attrs(attrs)
+ end
+
+ defp cast_access_attrs(changeset, attrs) do
+ case get_field(changeset, :access_type) do
+ :protected ->
+ changeset
+ |> cast(attrs, [:password])
+ |> validate_required([:password])
+ |> validate_length(:password, min: 12)
+
+ _other ->
+ changeset
+ end
end
@doc """
diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex
index 1b7c2f497..eaf59d650 100644
--- a/lib/livebook/session.ex
+++ b/lib/livebook/session.ex
@@ -192,6 +192,14 @@ defmodule Livebook.Session do
GenServer.call(pid, :get_notebook, @timeout)
end
+ @doc """
+ Returns the current app settings.
+ """
+ @spec get_app_settings(pid()) :: Notebook.AppSettings.t()
+ def get_app_settings(pid) do
+ GenServer.call(pid, :get_app_settings, @timeout)
+ end
+
@doc """
Subscribes to session messages.
@@ -827,6 +835,10 @@ defmodule Livebook.Session do
{:reply, state.data.notebook, state}
end
+ def handle_call(:get_app_settings, _from, state) do
+ {:reply, state.data.notebook.app_settings, state}
+ end
+
def handle_call(:save_sync, _from, state) do
{:reply, :ok, maybe_save_notebook_sync(state)}
end
diff --git a/lib/livebook_web/channels/js_view_channel.ex b/lib/livebook_web/channels/js_view_channel.ex
index 239905295..7ca6b2d6c 100644
--- a/lib/livebook_web/channels/js_view_channel.ex
+++ b/lib/livebook_web/channels/js_view_channel.ex
@@ -2,14 +2,25 @@ defmodule LivebookWeb.JSViewChannel do
use Phoenix.Channel
@impl true
- def join("js_view", %{"session_id" => session_id, "client_id" => client_id}, socket) do
- {:ok, assign(socket, session_id: session_id, client_id: client_id, ref_with_info: %{})}
+ def join("js_view", %{"session_token" => session_token}, socket) do
+ case Phoenix.Token.verify(LivebookWeb.Endpoint, "session", session_token) do
+ {:ok, data} ->
+ {:ok,
+ assign(socket,
+ session_id: data.session_id,
+ client_id: data.client_id,
+ ref_with_info: %{}
+ )}
+
+ _error ->
+ {:error, %{reason: "invalid token"}}
+ end
end
@impl true
- def handle_in("connect", %{"session_token" => session_token, "ref" => ref, "id" => id}, socket) do
- {:ok, data} = Phoenix.Token.verify(LivebookWeb.Endpoint, "js view", session_token)
- %{pid: pid} = data
+ def handle_in("connect", %{"connect_token" => connect_token, "ref" => ref, "id" => id}, socket) do
+ {:ok, %{pid: pid}} =
+ Phoenix.Token.verify(LivebookWeb.Endpoint, "js-view-connect", connect_token)
send(pid, {:connect, self(), %{origin: socket.assigns.client_id, ref: ref}})
diff --git a/lib/livebook_web/channels/socket.ex b/lib/livebook_web/channels/socket.ex
index 4c9a8a688..d39ac8bad 100644
--- a/lib/livebook_web/channels/socket.ex
+++ b/lib/livebook_web/channels/socket.ex
@@ -5,9 +5,10 @@ defmodule LivebookWeb.Socket do
@impl true
def connect(_params, socket, info) do
- auth_mode = Livebook.Config.auth_mode()
-
- if LivebookWeb.AuthPlug.authenticated?(info.session || %{}, info.uri.port, auth_mode) do
+ # The session is present only if the CSRF token is valid. We rely
+ # on CSRF token, because we don't check connection origin as noted
+ # in LivebookWeb.Endpoint.
+ if info.session do
{:ok, socket}
else
:error
diff --git a/lib/livebook_web/controllers/auth_controller.ex b/lib/livebook_web/controllers/auth_controller.ex
index e06918725..29fb8d41b 100644
--- a/lib/livebook_web/controllers/auth_controller.ex
+++ b/lib/livebook_web/controllers/auth_controller.ex
@@ -1,7 +1,7 @@
defmodule LivebookWeb.AuthController do
use LivebookWeb, :controller
- plug :require_unauthenticated
+ plug(:require_unauthenticated)
alias LivebookWeb.AuthPlug
@@ -15,8 +15,14 @@ defmodule LivebookWeb.AuthController do
end
end
- def index(conn, params) do
- render(conn, "index.html", auth_mode: Livebook.Config.auth_mode(), errors: params["errors"])
+ def index(conn, %{"redirect_to" => path}) do
+ conn
+ |> put_session(:redirect_to, path)
+ |> redirect(to: current_path(conn, %{}))
+ end
+
+ def index(conn, _params) do
+ render(conn, "index.html", errors: [], auth_mode: Livebook.Config.auth_mode())
end
def authenticate(conn, %{"password" => password}) do
@@ -40,7 +46,8 @@ defmodule LivebookWeb.AuthController do
end
defp render_form_error(conn, auth_mode) do
- index(conn, %{"errors" => [{"%{auth_mode} is invalid", [auth_mode: auth_mode]}]})
+ errors = [{"%{auth_mode} is invalid", [auth_mode: auth_mode]}]
+ render(conn, "index.html", errors: errors, auth_mode: auth_mode)
end
defp redirect_to(conn) do
diff --git a/lib/livebook_web/live/app_auth_live.ex b/lib/livebook_web/live/app_auth_live.ex
new file mode 100644
index 000000000..3d5508a1e
--- /dev/null
+++ b/lib/livebook_web/live/app_auth_live.ex
@@ -0,0 +1,85 @@
+defmodule LivebookWeb.AppAuthLive do
+ use LivebookWeb, :live_view
+
+ @impl true
+ def mount(%{"slug" => slug}, _session, socket) when not socket.assigns.app_authenticated? do
+ {:ok, assign(socket, slug: slug, password: "", errors: [])}
+ end
+
+ def mount(%{"slug" => slug}, _session, socket) do
+ {:ok, push_navigate(socket, to: Routes.app_path(socket, :page, slug))}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+ This app is password-protected
+
+
+
+
Type the app password to access it or
+
login into Livebook .
+
+
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("authenticate", %{"password" => password}, socket) do
+ socket =
+ if LivebookWeb.AppAuthHook.valid_password?(password, socket.assigns.app_settings) do
+ token = LivebookWeb.AppAuthHook.get_auth_token(socket.assigns.app_settings)
+ push_event(socket, "persist_app_auth", %{"slug" => socket.assigns.slug, "token" => token})
+ else
+ assign(socket, password: password, errors: [{"app password is invalid", []}])
+ end
+
+ {:noreply, socket}
+ end
+
+ def handle_event("app_auth_persisted", %{}, socket) do
+ {:noreply, push_navigate(socket, to: Routes.app_path(socket, :page, socket.assigns.slug))}
+ end
+end
diff --git a/lib/livebook_web/live/app_live.ex b/lib/livebook_web/live/app_live.ex
index a5e11cd72..8ac6d2e36 100644
--- a/lib/livebook_web/live/app_live.ex
+++ b/lib/livebook_web/live/app_live.ex
@@ -6,37 +6,41 @@ defmodule LivebookWeb.AppLive do
alias Livebook.Notebook.Cell
@impl true
- def mount(%{"slug" => slug}, _session, socket) do
- case Livebook.Apps.fetch_session_by_slug(slug) do
- {:ok, %{pid: session_pid, id: session_id}} ->
+ def mount(%{"slug" => slug}, _session, socket) when socket.assigns.app_authenticated? do
+ {:ok, %{pid: session_pid, id: session_id}} = Livebook.Apps.fetch_session_by_slug(slug)
+
+ {data, client_id} =
+ if connected?(socket) do
{data, client_id} =
- if connected?(socket) do
- {data, client_id} =
- Session.register_client(session_pid, self(), socket.assigns.current_user)
+ Session.register_client(session_pid, self(), socket.assigns.current_user)
- Session.subscribe(session_id)
+ Session.subscribe(session_id)
- {data, client_id}
- else
- data = Session.get_data(session_pid)
- {data, nil}
- end
+ {data, client_id}
+ else
+ data = Session.get_data(session_pid)
+ {data, nil}
+ end
- session = Session.get_by_pid(session_pid)
+ session = Session.get_by_pid(session_pid)
- {:ok,
- socket
- |> assign(
- slug: slug,
- session: session,
- page_title: get_page_title(data.notebook.name),
- client_id: client_id,
- data_view: data_to_view(data)
- )
- |> assign_private(data: data)}
+ {:ok,
+ socket
+ |> assign(
+ slug: slug,
+ session: session,
+ page_title: get_page_title(data.notebook.name),
+ client_id: client_id,
+ data_view: data_to_view(data)
+ )
+ |> assign_private(data: data)}
+ end
- :error ->
- {:ok, redirect(socket, to: Routes.home_path(socket, :page))}
+ def mount(%{"slug" => slug}, _session, socket) do
+ if connected?(socket) do
+ {:ok, push_navigate(socket, to: Routes.app_auth_path(socket, :page, slug))}
+ else
+ {:ok, socket}
end
end
@@ -49,7 +53,7 @@ defmodule LivebookWeb.AppLive do
end
@impl true
- def render(assigns) do
+ def render(assigns) when assigns.app_authenticated? do
~H"""
+
+
+ """
+ end
+
defp get_page_title(notebook_name) do
"Livebook - #{notebook_name}"
end
diff --git a/lib/livebook_web/live/hooks/app_auth_hook.ex b/lib/livebook_web/live/hooks/app_auth_hook.ex
new file mode 100644
index 000000000..8aaae0757
--- /dev/null
+++ b/lib/livebook_web/live/hooks/app_auth_hook.ex
@@ -0,0 +1,91 @@
+defmodule LivebookWeb.AppAuthHook do
+ import Phoenix.Component
+ import Phoenix.LiveView
+
+ alias LivebookWeb.Router.Helpers, as: Routes
+
+ # For apps with password, we want to store the hashed password
+ # (let's call it token) in the session, as we do for the main auth.
+ # However, the session uses cookies, which have a ~4kb size limit.
+ # We could use multiple cookies, however there are also limits on
+ # the number of cookies, and we don't want the browser to clear
+ # them all at some point. Additionally, accumulating cookies for
+ # all apps would imply larger payloads on every regular request.
+ #
+ # Since we don't have any persistence on the server side, the other
+ # option is to use the browser local storage and manage it through
+ # JavaScript. Therefore, the whole auth is built using LiveView.
+ # The flows are:
+ #
+ # * unauthenticated - the auth LiveView shows a regular form and
+ # validates the user-provided password. Once the password is
+ # correct, it pushes an event to the client to store the token.
+ # Once the token is stored it redirects to the app page.
+ #
+ # * authenticated - on dead render the app LiveView renders just
+ # a loading screen. On the client side, provided it's the app
+ # page, we read the token from local storage (if stored) and
+ # send it in mount connect params via the socket. Then on the
+ # server we use that token to authenticate.
+ #
+ # This module defines a hook that sets the `:app_authenticated?`
+ # assign to reflect the current authentication state. For public
+ # apps (or in case the user has full access) it is set to `true`
+ # on both dead and live render.
+
+ def on_mount(:default, %{"slug" => slug}, session, socket) do
+ case Livebook.Apps.fetch_settings_by_slug(slug) do
+ {:ok, %{access_type: :public} = app_settings} ->
+ {:cont, assign(socket, app_authenticated?: true, app_settings: app_settings)}
+
+ {:ok, %{access_type: :protected} = app_settings} ->
+ app_authenticated? =
+ livebook_authenticated?(session, socket) or has_valid_token?(socket, app_settings)
+
+ {:cont,
+ assign(socket, app_authenticated?: app_authenticated?, app_settings: app_settings)}
+
+ :error ->
+ {:halt, redirect(socket, to: Routes.home_path(socket, :page))}
+ end
+ end
+
+ defp livebook_authenticated?(session, socket) do
+ uri = get_connect_info(socket, :uri)
+ LivebookWeb.AuthPlug.authenticated?(session, uri.port, Livebook.Config.auth_mode())
+ end
+
+ defp has_valid_token?(socket, app_settings) do
+ connect_params = get_connect_params(socket) || %{}
+
+ if token = connect_params["app_auth_token"] do
+ valid_auth_token?(token, app_settings)
+ else
+ false
+ end
+ end
+
+ @doc """
+ Generates auth token that can be sent to the client.
+ """
+ @spec get_auth_token(Livebook.Notebook.AppSettings.t()) :: String.t()
+ def get_auth_token(app_settings) do
+ :crypto.hash(:sha256, app_settings.password) |> Base.encode64()
+ end
+
+ @doc """
+ Checks the given token is valid.
+ """
+ @spec valid_auth_token?(String.t(), Livebook.Notebook.AppSettings.t()) :: String.t()
+ def valid_auth_token?(token, app_settings) do
+ Plug.Crypto.secure_compare(token, get_auth_token(app_settings))
+ end
+
+ @doc """
+ Checks if the given password is valid.
+ """
+ @spec valid_password?(String.t(), Livebook.Notebook.AppSettings.t()) :: String.t()
+ def valid_password?(password, app_settings) do
+ Plug.Crypto.secure_compare(password, app_settings.password)
+ end
+end
diff --git a/lib/livebook_web/live/hooks/auth_hook.ex b/lib/livebook_web/live/hooks/auth_hook.ex
index ad7ac9070..1f50eb6da 100644
--- a/lib/livebook_web/live/hooks/auth_hook.ex
+++ b/lib/livebook_web/live/hooks/auth_hook.ex
@@ -1,6 +1,8 @@
defmodule LivebookWeb.AuthHook do
import Phoenix.LiveView
+ alias LivebookWeb.Router.Helpers, as: Routes
+
def on_mount(:default, _params, session, socket) do
uri = get_connect_info(socket, :uri)
auth_mode = Livebook.Config.auth_mode()
@@ -8,7 +10,7 @@ defmodule LivebookWeb.AuthHook do
if LivebookWeb.AuthPlug.authenticated?(session || %{}, uri.port, auth_mode) do
{:cont, socket}
else
- {:halt, socket}
+ {:halt, redirect(socket, to: Routes.home_path(socket, :page))}
end
end
end
diff --git a/lib/livebook_web/live/hub/new/enterprise_component.ex b/lib/livebook_web/live/hub/new/enterprise_component.ex
index 65904f37a..cbe0c56b9 100644
--- a/lib/livebook_web/live/hub/new/enterprise_component.ex
+++ b/lib/livebook_web/live/hub/new/enterprise_component.ex
@@ -49,7 +49,7 @@ defmodule LivebookWeb.Hub.New.EnterpriseComponent do
<.input_wrapper form={f} field={:token} class="flex flex-col space-y-1">
Token
<%= password_input(f, :token,
- value: get_field(@changeset, :token),
+ value: input_value(f, :token),
class: "input w-full phx-form-error:border-red-300",
spellcheck: "false",
autocomplete: "off",
diff --git a/lib/livebook_web/live/hub/new/fly_component.ex b/lib/livebook_web/live/hub/new/fly_component.ex
index ed0db673e..15334f51b 100644
--- a/lib/livebook_web/live/hub/new/fly_component.ex
+++ b/lib/livebook_web/live/hub/new/fly_component.ex
@@ -1,7 +1,7 @@
defmodule LivebookWeb.Hub.New.FlyComponent do
use LivebookWeb, :live_component
- import Ecto.Changeset, only: [get_field: 2, add_error: 3]
+ import Ecto.Changeset, only: [add_error: 3]
alias Livebook.Hubs.{Fly, FlyClient}
@@ -39,7 +39,7 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
phx_change: "fetch_data",
phx_debounce: "blur",
phx_target: @myself,
- value: access_token(@changeset),
+ value: input_value(f, :access_token),
class: "input w-full phx-form-error:border-red-300",
autofocus: true,
spellcheck: "false",
@@ -141,6 +141,4 @@ defmodule LivebookWeb.Hub.New.FlyComponent do
[disabled_option] ++ options
end
-
- defp access_token(changeset), do: get_field(changeset, :access_token)
end
diff --git a/lib/livebook_web/live/js_view_component.ex b/lib/livebook_web/live/js_view_component.ex
index 194a8bd44..37b409555 100644
--- a/lib/livebook_web/live/js_view_component.ex
+++ b/lib/livebook_web/live/js_view_component.ex
@@ -21,9 +21,8 @@ defmodule LivebookWeb.JSViewComponent do
Routes.session_path(@socket, :show_asset, @session_id, @js_view.assets.hash, [])
}
data-js-path={@js_view.assets.js_path}
- data-session-token={session_token(@js_view.pid)}
- data-session-id={@session_id}
- data-client-id={@client_id}
+ data-session-token={session_token(@session_id, @client_id)}
+ data-connect-token={connect_token(@js_view.pid)}
data-iframe-local-port={LivebookWeb.IframeEndpoint.port()}
data-iframe-url={Livebook.Config.iframe_url()}
data-timeout-message={@timeout_message}
@@ -32,7 +31,14 @@ defmodule LivebookWeb.JSViewComponent do
"""
end
- defp session_token(pid) do
- Phoenix.Token.sign(LivebookWeb.Endpoint, "js view", %{pid: pid})
+ defp session_token(session_id, client_id) do
+ Phoenix.Token.sign(LivebookWeb.Endpoint, "session", %{
+ session_id: session_id,
+ client_id: client_id
+ })
+ end
+
+ defp connect_token(pid) do
+ Phoenix.Token.sign(LivebookWeb.Endpoint, "js-view-connect", %{pid: pid})
end
end
diff --git a/lib/livebook_web/live/live_helpers.ex b/lib/livebook_web/live/live_helpers.ex
index ea51a3b85..ce64c523a 100644
--- a/lib/livebook_web/live/live_helpers.ex
+++ b/lib/livebook_web/live/live_helpers.ex
@@ -286,10 +286,20 @@ defmodule LivebookWeb.LiveHelpers do
|> assign_new(:label, fn -> nil end)
|> assign_new(:tooltip, fn -> nil end)
|> assign_new(:disabled, fn -> false end)
+ |> assign_new(:checked_value, fn -> "true" end)
+ |> assign_new(:unchecked_value, fn -> "false" end)
|> assign_new(:class, fn -> "" end)
|> assign(
:attrs,
- assigns_to_attributes(assigns, [:label, :name, :checked, :disabled, :class])
+ assigns_to_attributes(assigns, [
+ :label,
+ :name,
+ :checked,
+ :disabled,
+ :checked_value,
+ :unchecked_value,
+ :class
+ ])
)
~H"""
@@ -298,10 +308,10 @@ defmodule LivebookWeb.LiveHelpers do
<%= @label %>
<% end %>
-
+
-
-
- <.runtime_info data_view={@data_view} session={@session} socket={@socket} />
+
+ <.runtime_info data_view={@data_view} session={@session} socket={@socket} />
+
diff --git a/lib/livebook_web/live/session_live/app_info_component.ex b/lib/livebook_web/live/session_live/app_info_component.ex
index bba11a65c..90f3e53c6 100644
--- a/lib/livebook_web/live/session_live/app_info_component.ex
+++ b/lib/livebook_web/live/session_live/app_info_component.ex
@@ -1,6 +1,7 @@
defmodule LivebookWeb.SessionLive.AppInfoComponent do
use LivebookWeb, :live_component
+ alias Livebook.Notebook.AppSettings
alias LivebookWeb.Router.Helpers, as: Routes
@impl true
@@ -13,7 +14,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
changeset =
case socket.assigns do
%{changeset: changeset} when changeset.data == assigns.settings -> changeset
- _ -> Livebook.Notebook.AppSettings.change(assigns.settings)
+ _ -> AppSettings.change(assigns.settings)
end
{:ok,
@@ -67,14 +68,45 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
phx-target={@myself}
autocomplete="off"
>
- <.input_wrapper form={f} field={:slug} class="flex flex-col space-y-1">
-
Slug
- <%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %>
-
+
+ <.input_wrapper form={f} field={:slug} class="flex flex-col space-y-1">
+
Slug
+ <%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %>
+
+ <.input_wrapper form={f} field={:access_type} class="flex flex-col space-y-1">
+ <.switch_checkbox
+ id={input_id(f, :access_type)}
+ name={input_name(f, :access_type)}
+ label="Password-protected"
+ checked={Ecto.Changeset.get_field(@changeset, :access_type) == :protected}
+ checked_value="protected"
+ unchecked_value="public"
+ />
+
+ <%= if Ecto.Changeset.get_field(@changeset, :access_type) == :protected do %>
+ <.input_wrapper form={f} field={:password} class="flex flex-col space-y-1">
+ <.with_password_toggle id={input_id(f, :password) <> "-toggle"}>
+ <%= password_input(f, :password,
+ value: input_value(f, :password),
+ class: "input",
+ spellcheck: "false",
+ phx_debounce: "blur"
+ ) %>
+
+
+ <% end %>
+
Deploy
+
+ Reset
+
<% end %>
@@ -194,10 +226,19 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
end
@impl true
- def handle_event("validate", %{"app_settings" => params}, socket) do
- changeset = Livebook.Notebook.AppSettings.change(socket.assigns.settings, params)
+ def handle_event("validate", %{"_target" => ["reset"]}, socket) do
+ settings = AppSettings.new()
+ Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
+ {:noreply, assign(socket, changeset: AppSettings.change(settings))}
+ end
- with {:ok, settings} <- Livebook.Notebook.AppSettings.update(socket.assigns.settings, params) do
+ def handle_event("validate", %{"app_settings" => params}, socket) do
+ changeset =
+ socket.assigns.settings
+ |> AppSettings.change(params)
+ |> Map.put(:action, :validate)
+
+ with {:ok, settings} <- AppSettings.update(socket.assigns.settings, params) do
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
end
@@ -205,7 +246,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
end
def handle_event("deploy", %{"app_settings" => params}, socket) do
- case Livebook.Notebook.AppSettings.update(socket.assigns.settings, params) do
+ case AppSettings.update(socket.assigns.settings, params) do
{:ok, settings} ->
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
diff --git a/lib/livebook_web/plugs/auth_plug.ex b/lib/livebook_web/plugs/auth_plug.ex
index 1e2b5911b..470dede80 100644
--- a/lib/livebook_web/plugs/auth_plug.ex
+++ b/lib/livebook_web/plugs/auth_plug.ex
@@ -25,6 +25,7 @@ defmodule LivebookWeb.AuthPlug do
@doc """
Stores in the session the secret for the given mode.
"""
+ @spec store(Plug.Conn.t(), Livebook.Config.auth_mode(), String.t()) :: Plug.Conn.t()
def store(conn, mode, value) do
conn
|> put_session(key(conn.port, mode), hash(value))
diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex
index a0bb4f2fd..ad336bfe8 100644
--- a/lib/livebook_web/router.ex
+++ b/lib/livebook_web/router.ex
@@ -26,6 +26,10 @@ defmodule LivebookWeb.Router do
plug LivebookWeb.UserPlug
end
+ pipeline :user do
+ plug LivebookWeb.UserPlug
+ end
+
pipeline :js_view_assets do
plug :put_secure_browser_headers
plug :within_iframe_secure_headers
@@ -86,8 +90,6 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/package-search", SessionLive, :package_search
get "/sessions/:id/images/:image", SessionController, :show_image
live "/sessions/:id/*path_parts", SessionLive, :catch_all
-
- live "/apps/:slug", AppLive, :page
end
# Public authenticated URLs that people may be directed to
@@ -99,6 +101,15 @@ defmodule LivebookWeb.Router do
end
end
+ live_session :apps, on_mount: [LivebookWeb.AppAuthHook, LivebookWeb.UserHook] do
+ scope "/", LivebookWeb do
+ pipe_through [:browser, :user]
+
+ live "/apps/:slug", AppLive, :page
+ live "/apps/:slug/authenticate", AppAuthLive, :page
+ end
+ end
+
scope "/" do
pipe_through [:browser, :auth]
diff --git a/lib/livebook_web/templates/auth/index.html.heex b/lib/livebook_web/templates/auth/index.html.heex
index ebafb1aae..4329c4424 100644
--- a/lib/livebook_web/templates/auth/index.html.heex
+++ b/lib/livebook_web/templates/auth/index.html.heex
@@ -26,14 +26,14 @@
<%= if @auth_mode == :password do %>
<% else %>
<% end %>
- <%= for error <- @errors || [] do %>
+ <%= for error <- @errors do %>
<%= translate_error(error) %>
diff --git a/mix.lock b/mix.lock
index 90cb85e8f..9c12c9b68 100644
--- a/mix.lock
+++ b/mix.lock
@@ -18,10 +18,10 @@
"mint_web_socket": {:hex, :mint_web_socket, "1.0.2", "0933a4c82f2376e35569b2255cdce94f2e3f993c0d5b04c360460cb8beda7154", [:mix], [{:mint, ">= 1.4.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "067c5e15439be060f2ab57c468ee4ab29e39cb20b498ed990cb94f62db0efc3a"},
"phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
- "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
+ "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.3", "2e3d009422addf8b15c3dccc65ce53baccbe26f7cfd21d264680b5867789a9c1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8845177a866e017dcb7083365393c8f00ab061b8b6b2bda575891079dce81b2"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.15", "58137e648fca9da56d6e931c9c3001f895ff090291052035f395bc958b82f1a5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "888dd8ea986bebbda741acc65aef788c384d13db91fea416461b2e96aa06a193"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
diff --git a/test/livebook_web/channels/js_view_channel_test.exs b/test/livebook_web/channels/js_view_channel_test.exs
index a85d1af26..c22004a45 100644
--- a/test/livebook_web/channels/js_view_channel_test.exs
+++ b/test/livebook_web/channels/js_view_channel_test.exs
@@ -8,15 +8,14 @@ defmodule LivebookWeb.JSViewChannelTest do
LivebookWeb.Socket
|> socket()
|> subscribe_and_join(LivebookWeb.JSViewChannel, "js_view", %{
- "session_id" => session_id,
- "client_id" => Livebook.Utils.random_id()
+ "session_token" => session_token(session_id, Livebook.Utils.random_id())
})
%{socket: socket}
end
test "loads initial data from the widget server and pushes to the client", %{socket: socket} do
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@@ -25,8 +24,8 @@ defmodule LivebookWeb.JSViewChannelTest do
end
test "loads initial data for multiple connections separately", %{socket: socket} do
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id2"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id2"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@@ -38,7 +37,7 @@ defmodule LivebookWeb.JSViewChannelTest do
end
test "sends client events to the corresponding widget server", %{socket: socket} do
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@@ -49,7 +48,7 @@ defmodule LivebookWeb.JSViewChannelTest do
end
test "sends server events to the target client", %{socket: socket} do
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@@ -60,7 +59,7 @@ defmodule LivebookWeb.JSViewChannelTest do
describe "binary payload" do
test "initial data", %{socket: socket} do
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
payload = {:binary, %{message: "hey"}, <<1, 2, 3>>}
@@ -71,7 +70,7 @@ defmodule LivebookWeb.JSViewChannelTest do
end
test "form client to server", %{socket: socket} do
- push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
+ push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@@ -84,7 +83,14 @@ defmodule LivebookWeb.JSViewChannelTest do
end
end
- defp session_token() do
- Phoenix.Token.sign(LivebookWeb.Endpoint, "js view", %{pid: self()})
+ defp session_token(session_id, client_id) do
+ Phoenix.Token.sign(LivebookWeb.Endpoint, "session", %{
+ session_id: session_id,
+ client_id: client_id
+ })
+ end
+
+ defp connect_token() do
+ Phoenix.Token.sign(LivebookWeb.Endpoint, "js-view-connect", %{pid: self()})
end
end
diff --git a/test/livebook_web/live/app_auth_live_test.exs b/test/livebook_web/live/app_auth_live_test.exs
new file mode 100644
index 000000000..ecb40967f
--- /dev/null
+++ b/test/livebook_web/live/app_auth_live_test.exs
@@ -0,0 +1,125 @@
+defmodule LivebookWeb.AppAuthLiveTest do
+ use LivebookWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ setup ctx do
+ {slug, session} = create_app(ctx[:app_settings] || %{})
+
+ on_exit(fn ->
+ Livebook.Session.close(session.pid)
+ end)
+
+ Application.put_env(:livebook, :authentication_mode, :password)
+ Application.put_env(:livebook, :password, ctx[:livebook_password])
+
+ on_exit(fn ->
+ Application.put_env(:livebook, :authentication_mode, :disabled)
+ Application.delete_env(:livebook, :password)
+ end)
+
+ %{slug: slug}
+ end
+
+ defp create_app(app_settings_attrs) do
+ slug = Livebook.Utils.random_id()
+
+ app_settings =
+ Livebook.Notebook.AppSettings.new()
+ |> Map.replace!(:slug, slug)
+ |> Map.merge(app_settings_attrs)
+
+ notebook = %{Livebook.Notebook.new() | app_settings: app_settings}
+
+ {:ok, session} = Livebook.Sessions.create_session(notebook: notebook, mode: :app)
+ Livebook.Session.app_subscribe(session.id)
+ Livebook.Session.app_build(session.pid)
+
+ session_id = session.id
+ assert_receive {:app_registration_changed, ^session_id, true}
+
+ {slug, session}
+ end
+
+ # Integration tests for the authentication scenarios
+
+ describe "public app" do
+ @describetag app_settings: %{access_type: :public}
+
+ test "does not require authentication", %{conn: conn, slug: slug} do
+ {:ok, view, _} = live(conn, "/apps/#{slug}")
+ assert render(view) =~ "Untitled notebook"
+ end
+ end
+
+ describe "protected app" do
+ @describetag livebook_password: "long_livebook_password"
+ @describetag app_settings: %{access_type: :protected, password: "long_app_password"}
+
+ test "redirect to auth page when not authenticated", %{conn: conn, slug: slug} do
+ {:error, {:live_redirect, %{to: to}}} = live(conn, "/apps/#{slug}")
+ assert to == "/apps/#{slug}/authenticate"
+ end
+
+ test "shows an error on invalid password", %{conn: conn, slug: slug} do
+ {:ok, view, _} = live(conn, "/apps/#{slug}/authenticate")
+
+ assert view
+ |> element("form")
+ |> render_submit(%{password: "invalid password"}) =~ "app password is invalid"
+ end
+
+ test "persists authentication across requests", %{conn: conn, slug: slug} do
+ {:ok, view, _} = live(conn, "/apps/#{slug}/authenticate")
+
+ view
+ |> element("form")
+ |> render_submit(%{password: "long_app_password"})
+
+ # The token is stored on the client
+
+ assert_push_event(view, "persist_app_auth", %{"slug" => ^slug, "token" => token})
+
+ assert {:error, {:live_redirect, %{to: to}}} = render_hook(view, "app_auth_persisted")
+ assert to == "/apps/#{slug}"
+
+ # Then, the client passes the token in connect params
+
+ {:ok, view, _} =
+ conn
+ |> put_connect_params(%{"app_auth_token" => token})
+ |> live("/apps/#{slug}")
+
+ assert render(view) =~ "Untitled notebook"
+
+ # The auth page redirects to the app
+
+ {:error, {:live_redirect, %{to: to}}} =
+ conn
+ |> put_connect_params(%{"app_auth_token" => token})
+ |> live("/apps/#{slug}/authenticate")
+
+ assert to == "/apps/#{slug}"
+ end
+
+ test "redirects to the app page when authenticating in Livebook", %{conn: conn, slug: slug} do
+ conn = get(conn, "/authenticate?redirect_to=/apps/#{slug}")
+ assert redirected_to(conn) == "/authenticate"
+
+ conn = post(conn, "/authenticate", password: "long_livebook_password")
+ assert redirected_to(conn) == "/apps/#{slug}"
+
+ {:ok, view, _} = live(conn, "/apps/#{slug}")
+ assert render(view) =~ "Untitled notebook"
+
+ # The auth page redirects to the app
+
+ {:error, {:live_redirect, %{to: to}}} = live(conn, "/apps/#{slug}/authenticate")
+ assert to == "/apps/#{slug}"
+ end
+ end
+
+ test "redirects to homepage when accessing non-existent app", %{conn: conn} do
+ assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/apps/nonexistent")
+ end
+end