diff --git a/README.md b/README.md index 21a0d4a18..481f17e61 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,6 @@ The following environment variables can be used to configure Livebook on boot: * "cloudflare:" * "google_iap:" * "tailscale:" - * "teleport:" * "custom:YourElixirModule" See our authentication docs for more information: https://hexdocs.pm/livebook/authentication.html diff --git a/docs/authentication/teleport.md b/docs/authentication/teleport.md deleted file mode 100644 index 17f5b03ce..000000000 --- a/docs/authentication/teleport.md +++ /dev/null @@ -1,24 +0,0 @@ -# Teleport - -Setting up Teleport authentication will protect all routes of your Livebook instance. It is particularly useful for adding authentication to Livebook instances with deployed notebooks. Teleport authentication occurs in addition to [Livebook's authentication](../authentication.md) for deployed notebooks and admins. - -## How to - -To integrate Teleport authentication with Livebook, -set the `LIVEBOOK_IDENTITY_PROVIDER` environment variable to `LIVEBOOK_IDENTITY_PROVIDER=teleport:https://[cluster-name]:3080`. - -```bash -LIVEBOOK_IDENTITY_PROVIDER=teleport:https://[cluster-name]:3080 \ -livebook server -``` - -See https://goteleport.com/docs/application-access/jwt/introduction/ for more information -on how Teleport authentication works. - -## Livebook Teams - -[Livebook Teams](https://livebook.dev/teams/) users can deploy notebooks with the click of a button with pre-configured Zero Trust Authentication, shared team secrets, and file storages. Both online and airgapped deployment mechanisms are supported. - -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 3874a69b8..8df969822 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -35,13 +35,6 @@ defmodule Livebook.Config do name: "Tailscale", value: "Tailscale CLI socket path", module: Livebook.ZTA.Tailscale - }, - %{ - type: :teleport, - name: "Teleport", - value: "Teleport cluster address", - module: Livebook.ZTA.Teleport, - placeholder: "https://[cluster-name]:3080" } ] diff --git a/lib/livebook/teams/deployment_group.ex b/lib/livebook/teams/deployment_group.ex index b10d98b73..863e267e4 100644 --- a/lib/livebook/teams/deployment_group.ex +++ b/lib/livebook/teams/deployment_group.ex @@ -5,20 +5,7 @@ defmodule Livebook.Teams.DeploymentGroup do alias Livebook.Secrets.Secret alias Livebook.Teams.AgentKey - # If this list is updated, it must also be mirrored on Livebook Teams Server. - @zta_providers ~w(basic_auth cloudflare google_iap tailscale teleport)a - - @type t :: %__MODULE__{ - id: String.t() | nil, - name: String.t() | nil, - mode: :online | :offline, - hub_id: String.t() | nil, - clustering: :fly_io | nil, - zta_provider: :cloudflare | :google_iap | :tailscale | :teleport, - zta_key: String.t(), - secrets: [Secret.t()], - agent_keys: [AgentKey.t()] - } + @zta_providers Enum.map(Livebook.Config.identity_providers(), & &1.type) @primary_key {:id, :string, autogenerate: false} embedded_schema do diff --git a/lib/livebook/zta/teleport.ex b/lib/livebook/zta/teleport.ex deleted file mode 100644 index b3d0d57ce..000000000 --- a/lib/livebook/zta/teleport.ex +++ /dev/null @@ -1,90 +0,0 @@ -defmodule Livebook.ZTA.Teleport do - @behaviour Livebook.ZTA - - use GenServer - require Logger - - defstruct [:req_options, :name] - - @renew_afer 24 * 60 * 60 * 1000 - @fields %{"sub" => :id, "username" => :username} - @assertion "teleport-jwt-assertion" - @well_known_jwks_path "/.well-known/jwks.json" - - def start_link(opts) do - url = - opts[:identity_key] - |> URI.parse() - |> URI.append_path(@well_known_jwks_path) - |> URI.to_string() - - name = Keyword.fetch!(opts, :name) - options = [req_options: [url: url], name: name] - GenServer.start_link(__MODULE__, options, name: name) - end - - @impl true - def authenticate(name, conn, _opts) do - token = Plug.Conn.get_req_header(conn, @assertion) - jwks = Livebook.ZTA.get(name) - {conn, authenticate_user(token, jwks)} - end - - @impl true - def init(options) do - state = struct!(__MODULE__, options) - {:ok, renew(state)} - end - - @impl true - def handle_info(:renew, state) do - {:noreply, renew(state)} - end - - defp authenticate_user(token, jwks) do - with [encoded_token] <- token, - {:ok, %{fields: %{"exp" => exp, "nbf" => nbf}} = token} <- - verify_token(encoded_token, jwks), - :ok <- verify_timestamps(exp, nbf) do - for( - {k, v} <- token.fields, - new_k = @fields[k], - do: {new_k, v}, - into: %{payload: token.fields} - ) - else - _ -> - nil - end - end - - defp verify_token(token, keys) do - Enum.find_value(keys, :error, fn key -> - case JOSE.JWT.verify(key, token) do - {true, token, _s} -> {:ok, token} - _ -> nil - end - end) - end - - defp verify_timestamps(exp, nbf) do - now = DateTime.utc_now() - - with {:ok, exp} <- DateTime.from_unix(exp), - {:ok, nbf} <- DateTime.from_unix(nbf), - true <- DateTime.after?(exp, now), - true <- DateTime.after?(now, nbf) do - :ok - else - _ -> :error - end - end - - defp renew(state) do - keys = Req.request!(state.req_options).body["keys"] - jwks = JOSE.JWK.from_map(keys) - Process.send_after(self(), :renew, @renew_afer) - Livebook.ZTA.put(state.name, jwks) - state - end -end diff --git a/mix.exs b/mix.exs index ce82acd21..d4ffe86e3 100644 --- a/mix.exs +++ b/mix.exs @@ -230,7 +230,6 @@ defmodule Livebook.MixProject do "docs/authentication/cloudflare.md", "docs/authentication/google_iap.md", "docs/authentication/tailscale.md", - "docs/authentication/teleport.md", "docs/authentication/custom_auth.md" ] end diff --git a/test/livebook/zta/teleport_test.exs b/test/livebook/zta/teleport_test.exs deleted file mode 100644 index 84c00fa74..000000000 --- a/test/livebook/zta/teleport_test.exs +++ /dev/null @@ -1,126 +0,0 @@ -defmodule Livebook.ZTA.TeleportTest do - use ExUnit.Case, async: true - use Plug.Test - - alias Livebook.ZTA.Teleport - - @fields [:id, :name, :email] - @name Context.Test.Teleport - - @public_key """ - -----BEGIN PUBLIC KEY----- - MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 - q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== - -----END PUBLIC KEY----- - """ - - @private_key """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - setup do - bypass = Bypass.open() - - options = [ - name: @name, - identity_key: "http://localhost:#{bypass.port}" - ] - - Bypass.expect(bypass, "GET", "/.well-known/jwks.json", fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(%{keys: get_well_known_jwks()})) - end) - - token = create_token() - - conn = conn(:get, "/") |> put_req_header("teleport-jwt-assertion", token) - - {:ok, bypass: bypass, options: options, conn: conn, token: token} - end - - test "returns the user when it's valid", %{options: options, conn: conn} do - start_supervised!({Teleport, options}) - {_conn, user} = Teleport.authenticate(@name, conn, fields: @fields) - assert %{id: "my-user-id", username: "myusername", payload: %{}} = user - end - - test "returns nil when the exp is in the past", %{options: options, conn: conn} do - iat = DateTime.utc_now() |> DateTime.add(-10000) - exp = DateTime.utc_now() |> DateTime.add(-1000) - conn = put_req_header(conn, "teleport-jwt-assertion", create_token(iat, exp)) - start_supervised!({Teleport, options}) - - assert {_conn, nil} = Teleport.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the nbf is not reached yet", %{options: options, conn: conn} do - iat = DateTime.utc_now() |> DateTime.add(1000) - exp = DateTime.utc_now() |> DateTime.add(10000) - conn = put_req_header(conn, "teleport-jwt-assertion", create_token(iat, exp)) - start_supervised!({Teleport, options}) - - assert {_conn, nil} = Teleport.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the token is invalid", %{options: options} do - conn = conn(:get, "/") |> put_req_header("teleport-jwt-assertion", "invalid_token") - start_supervised!({Teleport, options}) - - assert {_conn, nil} = Teleport.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the assertion is invalid", %{options: options} do - conn = conn(:get, "/") |> put_req_header("invalid_assertion", create_token()) - start_supervised!({Teleport, options}) - - assert {_conn, nil} = Teleport.authenticate(@name, conn, fields: @fields) - end - - test "fails to start the process when the key is invalid", %{bypass: bypass, options: options} do - Bypass.expect(bypass, "GET", "/.well-known/jwks.json", fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(%{keys: ["invalid_key"]})) - end) - - assert_raise RuntimeError, fn -> - start_supervised!({Teleport, options}) - end - end - - defp get_well_known_jwks() do - jwk = @public_key |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() |> elem(1) |> Map.put("kid", "") - [jwk] - end - - defp create_token( - iat \\ DateTime.utc_now(), - exp \\ DateTime.add(DateTime.utc_now(), 1000) - ) do - iat = DateTime.to_unix(iat) - exp = DateTime.to_unix(exp) - - payload = %{ - "aud" => ["http://localhost:4000"], - "exp" => exp, - "iat" => iat, - "iss" => "my-teleport-custer", - "nbf" => iat, - "roles" => ["access", "editor", "member"], - "sub" => "my-user-id", - "traits" => %{"host_user_gid" => [""], "host_user_uid" => [""]}, - "username" => "myusername" - } - - @private_key - |> JOSE.JWK.from_pem() - |> JOSE.JWT.sign(payload) - |> JOSE.JWS.compact() - |> elem(1) - end -end