diff --git a/README.md b/README.md index 2807a4948d3..010f2bc033e 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ The following environment variables can be used to configure Livebook on boot: Livebook inside a cloud platform, such as Cloudflare and Google. Supported values are: + * "basic_auth::" * "cloudflare:" * "google_iap:" * "tailscale:" diff --git a/docs/deployment/basic_auth.md b/docs/deployment/basic_auth.md new file mode 100644 index 00000000000..ce878fb7ae2 --- /dev/null +++ b/docs/deployment/basic_auth.md @@ -0,0 +1,22 @@ +# Authentication with Basic Auth + +Setting up Basic Authentication will protect all routes of your notebook. It is particularly useful for adding authentication to deployed notebooks. Basic Authentication is provided in addition to [Livebook's authentication](../authentication.md) for authoring notebooks. + +## How to + +To integrate Basic Authentication with Livebook, set the `LIVEBOOK_IDENTITY_PROVIDER` environment variable to `basic_auth::`. + +To do it, run: + +```bash +LIVEBOOK_IDENTITY_PROVIDER=basic_auth:user:pass \ +livebook server +``` + +## Livebook Teams + +[Livebook Teams](https://livebook.dev/teams/) users have access to airgapped notebook deployment via Docker, with pre-configured Zero Trust Authentication, shared team secrets, and file storages. + +Furthermore, if you are deploying multi-session apps via [Livebook Teams](https://livebook.dev/teams/), you can programmatically access data from the authenticated user by calling [`Kino.Hub.app_info/0`](https://hexdocs.pm/kino/Kino.Hub.html#app_info/0). + +To get started, open up Livebook, click "Add Organization" on the sidebar, and visit the "Airgapped Deployment" section of your organization. diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 4ca01f289cb..a3f48454375 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -10,6 +10,14 @@ defmodule Livebook.Config do # # IMPORTANT: this list must be in sync with Livebook Teams. @identity_providers [ + %{ + type: :basic_auth, + name: "Basic Auth", + value: "Credentials (username:password)", + module: Livebook.ZTA.BasicAuth, + placeholder: "username:password", + input: "password" + }, %{ type: :cloudflare, name: "Cloudflare", diff --git a/lib/livebook/teams/deployment_group.ex b/lib/livebook/teams/deployment_group.ex index 2758deffc8a..f39876f8825 100644 --- a/lib/livebook/teams/deployment_group.ex +++ b/lib/livebook/teams/deployment_group.ex @@ -6,7 +6,7 @@ defmodule Livebook.Teams.DeploymentGroup do alias Livebook.Teams.AgentKey # If this list is updated, it must also be mirrored on Livebook Teams Server. - @zta_providers ~w(cloudflare google_iap tailscale teleport)a + @zta_providers ~w(basic_auth cloudflare google_iap tailscale teleport)a @type t :: %__MODULE__{ id: String.t() | nil, diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex new file mode 100644 index 00000000000..fcbbac6bbf6 --- /dev/null +++ b/lib/livebook/zta/basic_auth.ex @@ -0,0 +1,25 @@ +defmodule Livebook.ZTA.BasicAuth do + def child_spec(opts) do + %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}} + end + + def start_link(options) do + name = Keyword.fetch!(options, :name) + identity_key = Keyword.fetch!(options, :identity_key) + [username, password] = String.split(identity_key, ":", parts: 2) + + Livebook.ZTA.put(name, {username, password}) + :ignore + end + + def authenticate(name, conn, _options) do + {username, password} = Livebook.ZTA.get(name) + conn = Plug.BasicAuth.basic_auth(conn, username: username, password: password) + + if conn.halted do + {conn, nil} + else + {conn, %{payload: %{}}} + end + end +end diff --git a/lib/livebook_web/components/app_components.ex b/lib/livebook_web/components/app_components.ex index 7518b97c27d..89637cfda81 100644 --- a/lib/livebook_web/components/app_components.ex +++ b/lib/livebook_web/components/app_components.ex @@ -134,6 +134,7 @@ defmodule LivebookWeb.AppComponents do <.text_field :if={zta_metadata = zta_metadata(@form[:zta_provider].value)} field={@form[:zta_key]} + type={Map.get(zta_metadata, :input, "text")} label={zta_metadata.value} placeholder={zta_placeholder(zta_metadata)} phx-debounce diff --git a/mix.exs b/mix.exs index 0431252239c..06a69c4420a 100644 --- a/mix.exs +++ b/mix.exs @@ -219,6 +219,7 @@ defmodule Livebook.MixProject do {"README.md", title: "Welcome to Livebook"}, "docs/authentication.md", "docs/deployment/docker.md", + "docs/deployment/basic_auth.md", "docs/deployment/cloudflare.md", "docs/deployment/google_iap.md", "docs/deployment/tailscale.md", diff --git a/test/livebook/config_test.exs b/test/livebook/config_test.exs index 5287c83b61d..ec65c007a48 100644 --- a/test/livebook/config_test.exs +++ b/test/livebook/config_test.exs @@ -48,6 +48,11 @@ defmodule Livebook.ConfigTest do assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == {:zta, Livebook.ZTA.Cloudflare, "123"} end) + + with_env([TEST_IDENTITY_PROVIDER: "basic_auth:user:pass"], fn -> + assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == + {:zta, Livebook.ZTA.BasicAuth, "user:pass"} + end) end end diff --git a/test/livebook/zta/basic_auth_test.exs b/test/livebook/zta/basic_auth_test.exs new file mode 100644 index 00000000000..cc12526ceb4 --- /dev/null +++ b/test/livebook/zta/basic_auth_test.exs @@ -0,0 +1,42 @@ +defmodule Livebook.ZTA.BasicAuthTest do + use ExUnit.Case, async: true + use Plug.Test + + alias Livebook.ZTA.BasicAuth + + import Plug.BasicAuth, only: [encode_basic_auth: 2] + + @name Context.Test.BasicAuth + + setup do + username = "ChonkierCat" + password = Livebook.Utils.random_long_id() + options = [name: @name, identity_key: "#{username}:#{password}"] + + {:ok, username: username, password: password, options: options, conn: conn(:get, "/")} + end + + test "returns the user_identity when credentials are valid", context do + authorization = encode_basic_auth(context.username, context.password) + conn = put_req_header(context.conn, "authorization", authorization) + start_supervised!({BasicAuth, context.options}) + + assert {_conn, %{payload: %{}}} = BasicAuth.authenticate(@name, conn, []) + end + + test "returns nil when the username is invalid", context do + authorization = encode_basic_auth("foo", context.password) + conn = put_req_header(context.conn, "authorization", authorization) + start_supervised!({BasicAuth, context.options}) + + assert {_conn, nil} = BasicAuth.authenticate(@name, conn, []) + end + + test "returns nil when the password is invalid", context do + authorization = encode_basic_auth(context.username, Livebook.Utils.random_long_id()) + conn = put_req_header(context.conn, "authorization", authorization) + start_supervised!({BasicAuth, context.options}) + + assert {_conn, nil} = BasicAuth.authenticate(@name, conn, []) + end +end