diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex index 945bb49b54a..94424e2caa7 100644 --- a/lib/livebook/application.ex +++ b/lib/livebook/application.ex @@ -7,7 +7,6 @@ defmodule Livebook.Application do setup_tests() Logger.add_handlers(:livebook) - Livebook.ZTA.init() create_teams_hub = parse_teams_hub() setup_optional_dependencies() ensure_directories!() diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index b348bb79057..d8ffc158032 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -265,11 +265,11 @@ defmodule Livebook.Config do def identity_provider() do case Application.fetch_env(:livebook, :identity_provider) do {:ok, result} -> result - :error -> {:session, Livebook.ZTA.PassThrough, :unused} + :error -> {:session, NimbleZTA.PassThrough, :unused} end end - @identity_provider_no_id [Livebook.ZTA.BasicAuth, Livebook.ZTA.PassThrough] + @identity_provider_no_id [NimbleZTA.BasicAuth, NimbleZTA.PassThrough] @doc """ Returns if the identity data is readonly. @@ -758,10 +758,10 @@ defmodule Livebook.Config do end @identity_providers %{ - "basic_auth" => Livebook.ZTA.BasicAuth, - "cloudflare" => Livebook.ZTA.Cloudflare, - "google_iap" => Livebook.ZTA.GoogleIAP, - "tailscale" => Livebook.ZTA.Tailscale + "basic_auth" => NimbleZTA.BasicAuth, + "cloudflare" => NimbleZTA.Cloudflare, + "google_iap" => NimbleZTA.GoogleIAP, + "tailscale" => NimbleZTA.Tailscale } @doc """ diff --git a/lib/livebook/zta.ex b/lib/livebook/zta.ex deleted file mode 100644 index 1baa1babc04..00000000000 --- a/lib/livebook/zta.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule Livebook.ZTA do - @moduledoc """ - Enable zero-trust authentication within your Plug/Phoenix application. - - The following ZTA providers are supported: - - * Livebook.ZTA.Cloudflare - * Livebook.ZTA.GoogleIAP - * Livebook.ZTA.Tailscale - - We also support the following providers for dev/test/staging: - - * Livebook.ZTA.BasicAuth - HTTP basic authentication with a single user-pass - * Livebook.ZTA.PassThrough - always succeeds with no metadata - - You can find documentation for setting up these providers under [Livebook's - authentication section in the sidebar](https://hexdocs.pm/livebook/). - - ## Usage - - First you must add the ZTA provider of your choice to your supervision tree: - - {Livebook.ZTA.GoogleIAP, name: :google_iap, identity_key: "foobar"} - - where the `identity_key` is the identity provider specific key. - - Then you can use the provider `c:authenticate/3` callback to authenticate - users on every request: - - plug :zta - - def zta(conn, _opts) do - case Livebook.ZTA.GoogleIAP.authenticate(conn, :google_iap) do - # The provider is redirecting somewhere for follow up - {%{halted: true} = conn, nil} -> - conn - - # Authentication failed - {%{halted: false} = conn, nil} -> - send_resp(conn, 401, "Unauthorized") - - # Authentication succeeded - {conn, metadata} -> - put_session(conn, :user_metadata, metadata) - end - end - - Each provider may have specific optoins supported on `authenticate/3`. - """ && false - - @type name :: atom() - - @typedoc """ - A metadata of keys returned by zero-trust authentication provider. - - The following keys are supported: - - * `:id` - a string that uniquely identifies the user - * `:name` - the user name - * `:email` - the user email - * `:avatar_url` - the user avatar - * `:access_type` - the user access type - * `:groups` - the user groups - * `:payload` - the provider payload - - Note that none of the keys are required. The metadata returned depends - on the provider. - """ - @type metadata :: %{ - optional(:id) => String.t(), - optional(:name) => String.t(), - optional(:email) => String.t(), - optional(:avatar_url) => String.t() | nil, - optional(:access_type) => Livebook.Users.User.access_type(), - optional(:groups) => list(map()), - optional(:payload) => map() - } - - @doc """ - Each provider must specify a child specification for its processes. - - The `:name` and `:identity_key` keys are expected. - """ - @callback child_spec(name: name(), identity_key: String.t()) :: Supervisor.child_spec() - - @doc """ - Authenticates against the given name. - - It will return one of: - - * `{non_halted_conn, nil}` - the authentication failed and you must - halt the connection and render the appropriate report - - * `{halted_conn, nil}` - the authentication failed and the connection - was modified accordingly to request the credentials - - * `{non_halted_conn, metadata}` - the authentication succeed and the - following metadata about the user is available - - """ - @callback authenticate(name(), Plug.Conn.t(), keyword()) :: {Plug.Conn.t(), metadata() | nil} - - @doc false - def init do - :ets.new(__MODULE__, [:named_table, :public, :set, read_concurrency: true]) - end - - def get(name) do - :ets.lookup_element(__MODULE__, name, 2) - end - - def put(name, value) do - :ets.insert(__MODULE__, [{name, value}]) - end -end diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex deleted file mode 100644 index a07e59f2339..00000000000 --- a/lib/livebook/zta/basic_auth.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Livebook.ZTA.BasicAuth do - @behaviour Livebook.ZTA - - @impl true - 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 - - @impl true - def authenticate(name, conn, _opts) 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, %{}} - end - end -end diff --git a/lib/livebook/zta/cloudflare.ex b/lib/livebook/zta/cloudflare.ex deleted file mode 100644 index 1307e158cdc..00000000000 --- a/lib/livebook/zta/cloudflare.ex +++ /dev/null @@ -1,87 +0,0 @@ -defmodule Livebook.ZTA.Cloudflare do - @behaviour Livebook.ZTA - - use GenServer - require Logger - import Plug.Conn - - @assertion "cf-access-jwt-assertion" - @renew_after 24 * 60 * 60 * 1000 - @fields %{"user_uuid" => :id, "name" => :name, "email" => :email} - - defstruct [:req_options, :identity, :name] - - def start_link(opts) do - identity = opts[:custom_identity] || identity(opts[:identity_key]) - name = Keyword.fetch!(opts, :name) - options = [req_options: [url: identity.certs], identity: identity, name: name] - GenServer.start_link(__MODULE__, options, name: name) - end - - @impl true - def authenticate(name, conn, _opts) do - token = get_req_header(conn, @assertion) - {identity, keys} = Livebook.ZTA.get(name) - {conn, authenticate_user(token, identity, keys)} - 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 renew(state) do - Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}") - keys = Req.request!(state.req_options).body["keys"] - Process.send_after(self(), :renew, @renew_after) - Livebook.ZTA.put(state.name, {state.identity, keys}) - state - end - - defp authenticate_user(token, identity, keys) do - with [encoded_token] <- token, - {:ok, token} <- verify_token(encoded_token, keys), - :ok <- verify_iss(token, identity.iss), - {:ok, user} <- get_user_identity(encoded_token, identity.user_identity) do - for({k, v} <- user, new_k = @fields[k], do: {new_k, v}, into: %{payload: user}) - 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_iss(%{fields: %{"iss" => iss}}, iss), do: :ok - defp verify_iss(_, _), do: :error - - defp get_user_identity(token, url) do - cookie = "CF_Authorization=#{token}" - resp = Req.request!(url: url, headers: [cookie: cookie]) - if resp.status == 200, do: {:ok, resp.body}, else: :error - end - - defp identity(key) do - %{ - key: key, - key_type: "domain", - iss: "https://#{key}.cloudflareaccess.com", - certs: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/certs", - assertion: "cf-access-jwt-assertion", - email: "cf-access-authenticated-user-email", - user_identity: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/get-identity" - } - end -end diff --git a/lib/livebook/zta/google_iap.ex b/lib/livebook/zta/google_iap.ex deleted file mode 100644 index 123efef0d1a..00000000000 --- a/lib/livebook/zta/google_iap.ex +++ /dev/null @@ -1,85 +0,0 @@ -defmodule Livebook.ZTA.GoogleIAP do - @behaviour Livebook.ZTA - - use GenServer - require Logger - import Plug.Conn - - @assertion "x-goog-iap-jwt-assertion" - @renew_after 24 * 60 * 60 * 1000 - @fields %{"sub" => :id, "name" => :name, "email" => :email} - - defstruct [:req_options, :identity, :name] - - def start_link(opts) do - identity = opts[:custom_identity] || identity(opts[:identity_key]) - name = Keyword.fetch!(opts, :name) - options = [req_options: [url: identity.certs], identity: identity, name: name] - GenServer.start_link(__MODULE__, options, name: name) - end - - @impl true - def authenticate(name, conn, _opts) do - token = get_req_header(conn, @assertion) - {identity, keys} = Livebook.ZTA.get(name) - {conn, authenticate_user(token, identity, keys)} - 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 renew(state) do - Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}") - keys = Req.request!(state.req_options).body["keys"] - Process.send_after(self(), :renew, @renew_after) - Livebook.ZTA.put(state.name, {state.identity, keys}) - state - end - - defp authenticate_user(token, identity, keys) do - with [encoded_token] <- token, - {:ok, token} <- verify_token(encoded_token, keys), - :ok <- verify_iss(token, identity.iss, identity.key) 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_iss(%{fields: %{"iss" => iss, "aud" => key}}, iss, key), do: :ok - defp verify_iss(_, _, _), do: :error - - defp identity(key) do - %{ - key: key, - key_type: "aud", - iss: "https://cloud.google.com/iap", - certs: "https://www.gstatic.com/iap/verify/public_key-jwk", - assertion: "x-goog-iap-jwt-assertion", - email: "x-goog-authenticated-user-email", - user_identity: "https://www.googleapis.com/plus/v1/people/me" - } - end -end diff --git a/lib/livebook/zta/livebook_teams.ex b/lib/livebook/zta/livebook_teams.ex index 22c6b754951..34b1ae9b715 100644 --- a/lib/livebook/zta/livebook_teams.ex +++ b/lib/livebook/zta/livebook_teams.ex @@ -6,7 +6,7 @@ defmodule Livebook.ZTA.LivebookTeams do import Plug.Conn import Phoenix.Controller - @behaviour Livebook.ZTA + @behaviour NimbleZTA @impl true def child_spec(opts) do @@ -18,13 +18,13 @@ defmodule Livebook.ZTA.LivebookTeams do identity_key = Keyword.fetch!(opts, :identity_key) team = Livebook.Hubs.fetch_hub!(identity_key) - Livebook.ZTA.put(name, team) + NimbleZTA.put(name, team) :ignore end @impl true def authenticate(name, conn, _opts) do - team = Livebook.ZTA.get(name) + team = NimbleZTA.get(name) if Livebook.Hubs.TeamClient.identity_enabled?(team.id) do handle_request(conn, team, conn.params) @@ -33,10 +33,10 @@ defmodule Livebook.ZTA.LivebookTeams do end end - # Our extension to Livebook.ZTA to deal with logouts + # Our extension to NimbleZTA to deal with logouts def logout(name, conn) do token = get_session(conn, :livebook_teams_access_token) - team = Livebook.ZTA.get(name) + team = NimbleZTA.get(name) url = Livebook.Config.teams_url() @@ -164,7 +164,7 @@ defmodule Livebook.ZTA.LivebookTeams do @doc """ Returns the user metadata from given payload. """ - @spec build_metadata(String.t(), map()) :: Livebook.ZTA.metadata() + @spec build_metadata(String.t(), map()) :: NimbleZTA.metadata() def build_metadata(hub_id, payload) do %{ "id" => id, diff --git a/lib/livebook/zta/pass_through.ex b/lib/livebook/zta/pass_through.ex deleted file mode 100644 index 4e610410df9..00000000000 --- a/lib/livebook/zta/pass_through.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule Livebook.ZTA.PassThrough do - @behaviour Livebook.ZTA - - @impl true - def child_spec(_opts) do - %{id: __MODULE__, start: {Function, :identity, [:ignore]}} - end - - @impl true - def authenticate(_name, conn, _opts) do - {conn, %{}} - end -end diff --git a/lib/livebook/zta/tailscale.ex b/lib/livebook/zta/tailscale.ex deleted file mode 100644 index 15bf0fa42fa..00000000000 --- a/lib/livebook/zta/tailscale.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule Livebook.ZTA.Tailscale do - @behaviour Livebook.ZTA - require Logger - - @impl true - def child_spec(opts) do - %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}} - end - - def start_link(opts) do - name = Keyword.fetch!(opts, :name) - address = Keyword.fetch!(opts, :identity_key) - - if not String.starts_with?(address, "http") and - not File.exists?(address) do - Logger.error("Tailscale socket does not exist: #{inspect(address)}") - raise "invalid Tailscale ZTA configuration" - end - - Livebook.ZTA.put(name, address) - :ignore - end - - @impl true - def authenticate(name, conn, _opts) do - remote_ip = to_string(:inet_parse.ntoa(conn.remote_ip)) - tailscale_address = Livebook.ZTA.get(name) - user = authenticate_ip(remote_ip, tailscale_address) - {conn, user} - end - - defp authenticate_ip(remote_ip, address) do - {url, options} = - if String.starts_with?(address, "http") do - uri = URI.parse(address) - - options = - if uri.userinfo do - # Req does not handle userinfo as part of the URL - [auth: "Basic #{Base.encode64(uri.userinfo)}"] - else - [] - end - - url = to_string(%{uri | userinfo: nil, path: "/localapi/v0/whois?addr=#{remote_ip}:1"}) - - {url, options} - else - { - "http://local-tailscaled.sock/localapi/v0/whois?addr=#{remote_ip}:1", - [ - unix_socket: address, - # Req or Finch do not pass on the host from the URL when using a unix socket, - # so we set the host header explicitly - headers: [host: "local-tailscaled.sock"] - ] - } - end - - with {:ok, response} <- Req.get(url, options), - 200 <- response.status, - %{"UserProfile" => user} <- response.body do - %{ - id: to_string(user["ID"]), - name: user["DisplayName"], - email: user["LoginName"] - } - else - _ -> nil - end - end -end diff --git a/lib/livebook_web/plugs/user_plug.ex b/lib/livebook_web/plugs/user_plug.ex index 38c3e7d2778..831534d50c4 100644 --- a/lib/livebook_web/plugs/user_plug.ex +++ b/lib/livebook_web/plugs/user_plug.ex @@ -149,7 +149,7 @@ defmodule LivebookWeb.UserPlug do @zta_name LivebookWeb.ZTA @spec authenticate(module(), Plug.Conn.t() | map(), keyword()) :: - {Plug.Conn.t(), Livebook.ZTA.metadata()} + {Plug.Conn.t(), NimbleZTA.metadata()} if Mix.env() == :test do def authenticate(module, %Plug.Conn{} = conn, opts) do session = get_session(conn) diff --git a/mix.exs b/mix.exs index f6f592b9fac..b1dd27a668f 100644 --- a/mix.exs +++ b/mix.exs @@ -124,16 +124,16 @@ defmodule Livebook.MixProject do {:dns_cluster, "~> 0.1.2"}, {:kubereq, "~> 0.4.0"}, {:yaml_elixir, "~> 2.11"}, + {:logger_json, "~> 6.1"}, + {:req, "~> 0.5.8"}, + {:nimble_zta, "~> 0.1.0"}, + # Dev tools {:phoenix_live_reload, "~> 1.2", only: :dev}, {:tidewave, "~> 0.5", only: :dev}, + # Tests {:lazy_html, "~> 0.1.0", only: :test}, {:bypass, "~> 2.1", only: :test}, - {:logger_json, "~> 6.1"}, - # So that we can test Python evaluation in the same node {:pythonx, "~> 0.4.2", only: :test}, - # ZTA deps - {:jose, "~> 1.11.5"}, - {:req, "~> 0.5.8"}, # Docs {:ex_doc, "~> 0.30", only: :dev, runtime: false} ] diff --git a/mix.lock b/mix.lock index 1f2db7e346a..7ba203eb3b1 100644 --- a/mix.lock +++ b/mix.lock @@ -36,6 +36,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "nimble_zta": {:hex, :nimble_zta, "0.1.0", "7652c9de1d54fab20e13ed021a60198badb541cc34f77adf99a65691ccd269f0", [:mix], [{:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "63e985edd570e91df6dbe0dcf0112095a212771d18810b9186c9bb6bff5a54b0"}, "phoenix": {:hex, :phoenix, "1.8.0", "dc5d256bb253110266ded8c4a6a167e24fabde2e14b8e474d262840ae8d8ea18", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "15f6e9cb76646ad8d9f2947240519666fc2c4f29f8a93ad9c7664916ab4c167b"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, diff --git a/test/livebook/config_test.exs b/test/livebook/config_test.exs index 689498be40d..f9866d9c290 100644 --- a/test/livebook/config_test.exs +++ b/test/livebook/config_test.exs @@ -26,19 +26,19 @@ defmodule Livebook.ConfigTest do assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == {:custom, Module, nil} end) - with_env([TEST_IDENTITY_PROVIDER: "custom:Livebook.ZTA.PassThrough:extra"], fn -> + with_env([TEST_IDENTITY_PROVIDER: "custom:NimbleZTA.PassThrough:extra"], fn -> assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == - {:custom, Livebook.ZTA.PassThrough, "extra"} + {:custom, NimbleZTA.PassThrough, "extra"} end) with_env([TEST_IDENTITY_PROVIDER: "cloudflare:123"], fn -> assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == - {:zta, Livebook.ZTA.Cloudflare, "123"} + {:zta, NimbleZTA.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"} + {:zta, NimbleZTA.BasicAuth, "user:pass"} end) end end diff --git a/test/livebook/zta/basic_auth_test.exs b/test/livebook/zta/basic_auth_test.exs deleted file mode 100644 index 4d7241f2fb1..00000000000 --- a/test/livebook/zta/basic_auth_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Livebook.ZTA.BasicAuthTest do - use ExUnit.Case, async: true - import Plug.Test - import Plug.Conn - - 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, %{}} = 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 diff --git a/test/livebook/zta/cloudflare_test.exs b/test/livebook/zta/cloudflare_test.exs deleted file mode 100644 index be5a55371b7..00000000000 --- a/test/livebook/zta/cloudflare_test.exs +++ /dev/null @@ -1,115 +0,0 @@ -defmodule Livebook.ZTA.CloudflareTest do - use ExUnit.Case, async: true - import Plug.Test - import Plug.Conn - - alias Livebook.ZTA.Cloudflare - - @fields [:id, :name, :email] - @name Context.Test.Claudflare - - setup do - bypass = Bypass.open() - user_identity = Bypass.open() - - key = %{ - "kty" => "RSA", - "e" => "AQAB", - "use" => "sig", - "kid" => "bmlyt6y2uWrgWeUh3mENiSkEOR7Np3I8swSjlK98iX0", - "alg" => "RS256", - "n" => - "qvMgmj7GrjMAKxib9ODcdNyMwhsU1jwjvyAANrCJ5n1UcM82lZ5B3YP13zbPY3vRuufkW_GuA2cEZ8htMGT79kMsPz1cLrwIeUNOdGzncQQvBJVmQgw8NOuflVy5OajvfSe4a5PQmpC6BEp1d-Ix0S4BV2vWJUb0UtHg3bM4GgHTrnhHkSyXfpSZT4SNqnSOtiXiD-7lue52cPlZotkeTR2D4LTVSrsCdp21wGvAxXqnfpRcKYs5EyEmyTQ85zak7nBAReMrAqrRilXej8qTWGGIg1TRILvoCMd3nF5QjcjRCx2JMMHXG4tZNoK4QbEQlsdcd45B1VpE15TwgNTx4Q" - } - - token = - "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ0dWthQHBlcmFsdGEuY29tIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJsaXZlYm9vayIsImF1ZCI6ImxpdmVib29rd2ViIn0.ZP5LIrkfMHq2p8g3SMgC7RBt7899GeHkP9rzYKM3RvjDCBeYoxpeioLW2sXMT74QyJPxB4JUujRU3shSPIWNAxkjJVaBGwVTqsO_PR34DSx82q45qSkUnkSVXLl-2KgN4BoUUK7dmocP6yzhNQ3XGf6669n5UG69eMZdh9PApZ7GuyRUQ80ubpvakWaIpd9PIaORkptqDWVbyOIk3Z79AMUub0MSG1FpzYByAQoLswob24l2xVo95-aQrdatqLk1sJ43AZ6HLoMxkZkWobYYRMH5w65MkQckJ9NzI3Rk-VOUlg9ePo8OPRnvcGY-OozHXrjdzn2-j03xuP6x1J3Y7Q" - - options = [ - name: @name, - custom_identity: %{ - iss: "livebook", - key: "livebookweb", - certs: "http://localhost:#{bypass.port}", - user_identity: "http://localhost:#{user_identity.port}" - } - ] - - Bypass.expect(bypass, fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, JSON.encode!(%{keys: [key]})) - end) - - conn = conn(:get, "/") |> put_req_header("cf-access-jwt-assertion", token) - - {:ok, - bypass: bypass, token: token, options: options, user_identity: user_identity, conn: conn} - end - - test "returns the user_identity when the user is valid", context do - expected_user = %{ - "user_uuid" => "1234567890", - "name" => "Tuka Peralta", - "email" => "tuka@peralta.com" - } - - Bypass.expect_once(context.user_identity, fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, JSON.encode!(expected_user)) - end) - - start_supervised!({Cloudflare, context.options}) - {_conn, user} = Cloudflare.authenticate(@name, context.conn, fields: @fields) - - assert %{id: "1234567890", email: "tuka@peralta.com", name: "Tuka Peralta", payload: %{}} = - user - end - - test "returns nil when the user_identity fails", context do - Bypass.expect_once(context.user_identity, fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(403, "") - end) - - start_supervised!({Cloudflare, context.options}) - - assert {_conn, nil} = Cloudflare.authenticate(@name, context.conn, fields: @fields) - end - - test "returns nil when the iss is invalid", %{options: options, conn: conn} do - invalid_identity = Map.replace(options[:custom_identity], :iss, "invalid_iss") - options = Keyword.put(options, :custom_identity, invalid_identity) - start_supervised!({Cloudflare, options}) - - assert {_conn, nil} = Cloudflare.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the token is invalid", %{options: options} do - conn = conn(:get, "/") |> put_req_header("cf-access-jwt-assertion", "invalid_token") - start_supervised!({Cloudflare, options}) - - assert {_conn, nil} = Cloudflare.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the assertion is invalid", %{options: options, token: token} do - conn = conn(:get, "/") |> put_req_header("invalid_assertion", token) - start_supervised!({Cloudflare, options}) - - assert {_conn, nil} = Cloudflare.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the key is invalid", %{bypass: bypass, options: options, conn: conn} do - Bypass.expect_once(bypass, fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, JSON.encode!(%{keys: ["invalid_key"]})) - end) - - start_supervised!({Cloudflare, options}) - - assert {_conn, nil} = Cloudflare.authenticate(@name, conn, fields: @fields) - end -end diff --git a/test/livebook/zta/google_iap_test.exs b/test/livebook/zta/google_iap_test.exs deleted file mode 100644 index 67c591fcbb2..00000000000 --- a/test/livebook/zta/google_iap_test.exs +++ /dev/null @@ -1,94 +0,0 @@ -defmodule Livebook.ZTA.GoogleIAPTest do - use ExUnit.Case, async: true - import Plug.Test - import Plug.Conn - - alias Livebook.ZTA.GoogleIAP - - @fields [:id, :name, :email] - @name Context.Test.GoogleIAP - - setup do - bypass = Bypass.open() - - key = %{ - "kty" => "RSA", - "e" => "AQAB", - "use" => "sig", - "kid" => "bmlyt6y2uWrgWeUh3mENiSkEOR7Np3I8swSjlK98iX0", - "alg" => "RS256", - "n" => - "qvMgmj7GrjMAKxib9ODcdNyMwhsU1jwjvyAANrCJ5n1UcM82lZ5B3YP13zbPY3vRuufkW_GuA2cEZ8htMGT79kMsPz1cLrwIeUNOdGzncQQvBJVmQgw8NOuflVy5OajvfSe4a5PQmpC6BEp1d-Ix0S4BV2vWJUb0UtHg3bM4GgHTrnhHkSyXfpSZT4SNqnSOtiXiD-7lue52cPlZotkeTR2D4LTVSrsCdp21wGvAxXqnfpRcKYs5EyEmyTQ85zak7nBAReMrAqrRilXej8qTWGGIg1TRILvoCMd3nF5QjcjRCx2JMMHXG4tZNoK4QbEQlsdcd45B1VpE15TwgNTx4Q" - } - - token = - "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ0dWthQHBlcmFsdGEuY29tIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJsaXZlYm9vayIsImF1ZCI6ImxpdmVib29rd2ViIn0.ZP5LIrkfMHq2p8g3SMgC7RBt7899GeHkP9rzYKM3RvjDCBeYoxpeioLW2sXMT74QyJPxB4JUujRU3shSPIWNAxkjJVaBGwVTqsO_PR34DSx82q45qSkUnkSVXLl-2KgN4BoUUK7dmocP6yzhNQ3XGf6669n5UG69eMZdh9PApZ7GuyRUQ80ubpvakWaIpd9PIaORkptqDWVbyOIk3Z79AMUub0MSG1FpzYByAQoLswob24l2xVo95-aQrdatqLk1sJ43AZ6HLoMxkZkWobYYRMH5w65MkQckJ9NzI3Rk-VOUlg9ePo8OPRnvcGY-OozHXrjdzn2-j03xuP6x1J3Y7Q" - - options = [ - name: @name, - custom_identity: %{ - iss: "livebook", - key: "livebookweb", - certs: "http://localhost:#{bypass.port}" - } - ] - - Bypass.expect(bypass, fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, JSON.encode!(%{keys: [key]})) - end) - - conn = conn(:get, "/") |> put_req_header("x-goog-iap-jwt-assertion", token) - - {:ok, bypass: bypass, token: token, options: options, conn: conn} - end - - test "returns the user when it's valid", %{options: options, conn: conn} do - start_supervised!({GoogleIAP, options}) - {_conn, user} = GoogleIAP.authenticate(@name, conn, fields: @fields) - assert %{id: "1234567890", email: "tuka@peralta.com", payload: %{}} = user - end - - test "returns nil when the iss is invalid", %{options: options, conn: conn} do - invalid_identity = Map.replace(options[:custom_identity], :iss, "invalid_iss") - options = Keyword.put(options, :custom_identity, invalid_identity) - start_supervised!({GoogleIAP, options}) - - assert {_conn, nil} = GoogleIAP.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the aud is invalid", %{options: options, conn: conn} do - invalid_identity = Map.replace(options[:custom_identity], :key, "invalid_aud") - options = Keyword.put(options, :custom_identity, invalid_identity) - start_supervised!({GoogleIAP, options}) - - assert {_conn, nil} = GoogleIAP.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the token is invalid", %{options: options} do - conn = conn(:get, "/") |> put_req_header("x-goog-iap-jwt-assertion", "invalid_token") - start_supervised!({GoogleIAP, options}) - - assert {_conn, nil} = GoogleIAP.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the assertion is invalid", %{options: options, token: token} do - conn = conn(:get, "/") |> put_req_header("invalid_assertion", token) - start_supervised!({GoogleIAP, options}) - - assert {_conn, nil} = GoogleIAP.authenticate(@name, conn, fields: @fields) - end - - test "returns nil when the key is invalid", %{bypass: bypass, options: options, conn: conn} do - Bypass.expect_once(bypass, fn conn -> - conn - |> put_resp_content_type("application/json") - |> send_resp(200, JSON.encode!(%{keys: ["invalid_key"]})) - end) - - start_supervised!({GoogleIAP, options}) - - assert {_conn, nil} = GoogleIAP.authenticate(@name, conn, fields: @fields) - end -end diff --git a/test/livebook/zta/tailscale_test.exs b/test/livebook/zta/tailscale_test.exs deleted file mode 100644 index 22f5c5c4649..00000000000 --- a/test/livebook/zta/tailscale_test.exs +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Livebook.ZTA.TailscaleTest do - use ExUnit.Case, async: true - import Plug.Test - import Plug.Conn - alias Livebook.ZTA.Tailscale - - @moduletag unix: true - @fields [:id, :name, :email] - @name Context.Test.Tailscale - @path "/localapi/v0/whois" - - def valid_user_response(conn) do - conn - |> put_resp_content_type("application/json") - |> send_resp( - 200, - JSON.encode!(%{ - UserProfile: %{ - ID: 1_234_567_890, - DisplayName: "John", - LoginName: "john@example.org" - } - }) - ) - end - - setup do - bypass = Bypass.open() - conn = %{conn(:get, @path) | remote_ip: {151, 236, 219, 228}} - - options = [ - name: @name, - identity_key: "http://localhost:#{bypass.port}" - ] - - {:ok, bypass: bypass, options: options, conn: conn} - end - - test "returns the user when it's valid", %{bypass: bypass, options: options, conn: conn} do - Bypass.expect(bypass, fn conn -> - assert %{"addr" => "151.236.219.228:1"} = conn.query_params - valid_user_response(conn) - end) - - start_supervised!({Tailscale, options}) - {_conn, user} = Tailscale.authenticate(@name, conn, @fields) - assert %{id: "1234567890", email: "john@example.org", name: "John"} = user - end - - defmodule TestPlug do - def init(options), do: options - def call(conn, _opts), do: Livebook.ZTA.TailscaleTest.valid_user_response(conn) - end - - @tag :tmp_dir - test "returns valid user via unix socket", %{options: options, conn: conn, tmp_dir: tmp_dir} do - socket = Path.relative_to_cwd("#{tmp_dir}/bandit.sock") - options = Keyword.put(options, :identity_key, socket) - start_supervised!({Bandit, plug: TestPlug, ip: {:local, socket}, port: 0}) - start_supervised!({Tailscale, options}) - {_conn, user} = Tailscale.authenticate(@name, conn, @fields) - assert %{id: "1234567890", email: "john@example.org", name: "John"} = user - end - - test "raises when configured with missing unix socket", %{options: options} do - Process.flag(:trap_exit, true) - options = Keyword.put(options, :identity_key, "./invalid-socket.sock") - - assert ExUnit.CaptureLog.capture_log(fn -> - {:error, _} = start_supervised({Tailscale, options}) - end) =~ "Tailscale socket does not exist" - end - - test "returns nil when it's invalid", %{bypass: bypass, options: options} do - Bypass.expect_once(bypass, fn conn -> - assert %{"addr" => "151.236.219.229:1"} = conn.query_params - - conn - |> send_resp(404, "no match for IP:port") - end) - - conn = %{conn(:get, @path) | remote_ip: {151, 236, 219, 229}} - - start_supervised!({Tailscale, options}) - assert {_conn, nil} = Tailscale.authenticate(@name, conn, @fields) - end - - test "includes an authorization header when userinfo is provided", %{ - bypass: bypass, - options: options, - conn: conn - } do - options = Keyword.put(options, :identity_key, "http://:foobar@localhost:#{bypass.port}") - - Bypass.expect_once(bypass, fn conn -> - assert %{"addr" => "151.236.219.228:1"} = conn.query_params - assert Plug.Conn.get_req_header(conn, "authorization") == ["Basic OmZvb2Jhcg=="] - - conn - |> send_resp(404, "no match for IP:port") - end) - - start_supervised!({Tailscale, options}) - assert {_conn, nil} = Tailscale.authenticate(@name, conn, @fields) - end -end