Custom identity providers (#2301)

This commit is contained in:
José Valim 2023-10-25 09:44:09 +02:00 committed by GitHub
parent 875144cb66
commit c9d0c05bcc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 123 additions and 65 deletions

View file

@ -237,6 +237,10 @@ The following environment variables can be used to configure Livebook on boot:
* "cloudflare:<your-team-name (domain)>" * "cloudflare:<your-team-name (domain)>"
* "google_iap:<your-audience (aud)>" * "google_iap:<your-audience (aud)>"
* "tailscale:<tailscale-cli-socket-path>"
* "custom:YourElixirModule"
See our authentication docs for more information: https://hexdocs.pm/livebook/authentication.html
* `LIVEBOOK_IFRAME_PORT` - sets the port that Livebook serves iframes at. * `LIVEBOOK_IFRAME_PORT` - sets the port that Livebook serves iframes at.
This is relevant only when running Livebook without TLS. Defaults to 8081. This is relevant only when running Livebook without TLS. Defaults to 8081.

View file

@ -0,0 +1,48 @@
# Custom authentication
It is possible to provide custom Zero Trust Authentication (ZTA) inside Livebook's Docker images.
To do so, you must define a file with the `.exs` extension inside the `/app/user/extensions` of your Livebook image, for example, `/app/user/extensions/my_auth.exs`. This file should define at least one module, which implements the ZTA skeleton below:
```elixir
defmodule MyAuth do
use GenServer
@spec start_link(keyword) :: {:ok, pid()}
def start_link(opts) do
identity_key = opts[:identity_key]
GenServer.start_link(__MODULE__, identity_key, Keyword.take(opts, [:name]))
end
@spec authenticate(GenServer.server(), Plug.Conn.t(), keyword()) ::
{Plug.Conn.t(), map() | nil}
def authenticate(server, conn, opts \\ []) do
# Connects to the GenServer given by `server` and returns the user information.
# See `opts[:fields]` for the fields to be returned in the map.
# Return nil if the user cannot be authenticated.
end
end
```
Then you must configure Livebook to use the module above as your identity provider:
```bash
LIVEBOOK_IDENTITY_PROVIDER="custom:MyAuth"
```
Or, if you want to pass a custom identity key:
```bash
LIVEBOOK_IDENTITY_PROVIDER="custom:MyAuth:my-key"
```
Keep in mind that the identity provider contract in Livebook is still evolving and it may change in future releases. Additionally, your code may rely on two dependencies: [Req ~> 0.4](https://hexdocs.pm/req) and [JOSE ~> 1.11](https://hexdocs.pm/jose).
## Development
If you want to try your custom identity provider in development, you can [clone Livebook's git repository](https://github.com/livebook-dev/livebook) and then execute the following command inside Livebook's root folder:
```elixir
$ mix setup
$ LIVEBOOK_IDENTITY_PROVIDER="custom:MyAuth" elixir -r path/to/my_auth.exs -S mix phx.server
```

View file

@ -82,6 +82,12 @@ defmodule Livebook do
""" """
def config_runtime do def config_runtime do
if root = System.get_env("RELEASE_ROOT") do
for file <- Path.wildcard(Path.join(root, "user/extensions/*.exs")) do
Code.require_file(file)
end
end
import Config import Config
config :livebook, :random_boot_id, :crypto.strong_rand_bytes(3) config :livebook, :random_boot_id, :crypto.strong_rand_bytes(3)
@ -214,8 +220,7 @@ defmodule Livebook do
config :livebook, config :livebook,
:identity_provider, :identity_provider,
Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER") || Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER")
{LivebookWeb.SessionIdentity, :unused}
if dns_cluster_query = Livebook.Config.dns_cluster_query!("LIVEBOOK_CLUSTER") do if dns_cluster_query = Livebook.Config.dns_cluster_query!("LIVEBOOK_CLUSTER") do
config :livebook, :dns_cluster_query, dns_cluster_query config :livebook, :dns_cluster_query, dns_cluster_query

View file

@ -387,8 +387,8 @@ defmodule Livebook.Application do
end end
defp identity_provider() do defp identity_provider() do
{module, key} = Livebook.Config.identity_provider() {_type, module, key} = Livebook.Config.identity_provider()
[{module, name: LivebookWeb.ZTA, identity: [key: key]}] [{module, name: LivebookWeb.ZTA, identity_key: key}]
end end
defp serverless?() do defp serverless?() do

View file

@ -3,34 +3,28 @@ defmodule Livebook.Config do
@type auth_mode() :: :token | :password | :disabled @type auth_mode() :: :token | :password | :disabled
# Those are the public identity providers.
#
# There are still a :session and :custom identity providers,
# but those are handled internally.
@identity_providers [ @identity_providers [
%{
type: :session,
name: "Session",
value: "Cookie value",
module: LivebookWeb.SessionIdentity,
read_only: true
},
%{ %{
type: :cloudflare, type: :cloudflare,
name: "Cloudflare", name: "Cloudflare",
value: "Team name (domain)", value: "Team name (domain)",
module: Livebook.ZTA.Cloudflare, module: Livebook.ZTA.Cloudflare
read_only: false
}, },
%{ %{
type: :google_iap, type: :google_iap,
name: "Google IAP", name: "Google IAP",
value: "Audience (aud)", value: "Audience (aud)",
module: Livebook.ZTA.GoogleIAP, module: Livebook.ZTA.GoogleIAP
read_only: false
}, },
%{ %{
type: :tailscale, type: :tailscale,
name: "Tailscale", name: "Tailscale",
value: "Tailscale CLI socket path", value: "Tailscale CLI socket path",
module: Livebook.ZTA.Tailscale, module: Livebook.ZTA.Tailscale
read_only: false
} }
] ]
@ -38,12 +32,6 @@ defmodule Livebook.Config do
{Atom.to_string(provider.type), provider.module} {Atom.to_string(provider.type), provider.module}
end) end)
@identity_provider_module_to_type Map.new(@identity_providers, fn provider ->
{provider.module, provider.type}
end)
@identity_provider_read_only Enum.filter(@identity_providers, & &1.read_only)
@doc """ @doc """
Returns docker images to be used when generating sample Dockerfiles. Returns docker images to be used when generating sample Dockerfiles.
""" """
@ -249,6 +237,9 @@ defmodule Livebook.Config do
@doc """ @doc """
Returns all identity providers. Returns all identity providers.
Internal identity providers, such as session and custom,
are not included.
""" """
def identity_providers do def identity_providers do
@identity_providers @identity_providers
@ -257,7 +248,7 @@ defmodule Livebook.Config do
@doc """ @doc """
Returns the identity provider. Returns the identity provider.
""" """
@spec identity_provider() :: {module, binary} @spec identity_provider() :: {atom(), module, binary}
def identity_provider() do def identity_provider() do
Application.fetch_env!(:livebook, :identity_provider) Application.fetch_env!(:livebook, :identity_provider)
end end
@ -267,17 +258,8 @@ defmodule Livebook.Config do
""" """
@spec identity_provider_read_only?() :: boolean() @spec identity_provider_read_only?() :: boolean()
def identity_provider_read_only?() do def identity_provider_read_only?() do
{module, _} = Livebook.Config.identity_provider() {type, _module, _key} = Livebook.Config.identity_provider()
module in @identity_provider_read_only Map.has_key?(identity_provider_type_to_module(), type)
end
@doc """
Returns identity provider type.
"""
@spec identity_provider_type() :: atom()
def identity_provider_type() do
{module, _} = identity_provider()
Map.fetch!(@identity_provider_module_to_type, module)
end end
@doc """ @doc """
@ -696,18 +678,39 @@ defmodule Livebook.Config do
def identity_provider!(env) do def identity_provider!(env) do
if provider = System.get_env(env) do if provider = System.get_env(env) do
identity_provider!(env, provider) identity_provider!(env, provider)
else
{:session, LivebookWeb.SessionIdentity, :unused}
end end
end end
@doc """ @doc """
Parses and validates zero trust identity provider within context. Parses and validates zero trust identity provider within context.
iex> Livebook.Config.identity_provider!("ENV_VAR", "custom:Module")
{:custom, Module, nil}
iex> Livebook.Config.identity_provider!("ENV_VAR", "custom:LivebookWeb.SessionIdentity:extra")
{:custom, LivebookWeb.SessionIdentity, "extra"}
""" """
def identity_provider!(context, "custom:" <> module_key) do
destructure [module, key], String.split(module_key, ":", parts: 2)
module = Module.concat([module])
if Code.ensure_loaded?(module) do
{:custom, module, key}
else
abort!("module given as custom identity provider in #{context} could not be found")
end
end
def identity_provider!(context, provider) do def identity_provider!(context, provider) do
with [type, key] <- String.split(provider, ":", parts: 2), with [type, key] <- String.split(provider, ":", parts: 2),
%{^type => module} <- @identity_provider_type_to_module do %{^type => module} <- identity_provider_type_to_module() do
{module, key} {module, key}
else else
_ -> abort!("invalid configuration for identity provider given in #{context}") _ -> abort!("invalid configuration for identity provider given in #{context}")
end end
end end
defp identity_provider_type_to_module, do: @identity_provider_type_to_module
end end

View file

@ -30,7 +30,6 @@ defmodule Livebook.Hubs.Dockerfile do
zta_types = zta_types =
for provider <- Livebook.Config.identity_providers(), for provider <- Livebook.Config.identity_providers(),
not provider.read_only,
do: provider.type do: provider.type
types = %{ types = %{

View file

@ -2725,10 +2725,12 @@ defmodule Livebook.Session do
info = %{type: :multi_session} info = %{type: :multi_session}
if user = state.started_by do if user = state.started_by do
{type, _module, _key} = Livebook.Config.identity_provider()
started_by = started_by =
user user
|> Map.take([:id, :name, :email]) |> Map.take([:id, :name, :email])
|> Map.put(:source, Livebook.Config.identity_provider_type()) |> Map.put(:source, type)
Map.put(info, :started_by, started_by) Map.put(info, :started_by, started_by)
else else

View file

@ -7,18 +7,18 @@ defmodule Livebook.ZTA.Cloudflare do
@renew_afer 24 * 60 * 60 * 1000 @renew_afer 24 * 60 * 60 * 1000
@fields %{"user_uuid" => :id, "name" => :name, "email" => :email} @fields %{"user_uuid" => :id, "name" => :name, "email" => :email}
defstruct [:name, :req_options, :identity, :keys] defstruct [:req_options, :identity, :keys]
def start_link(opts) do def start_link(opts) do
identity = opts[:custom_identity] || identity(opts[:identity][:key]) identity = opts[:custom_identity] || identity(opts[:identity_key])
options = [req_options: [url: identity.certs], identity: identity, keys: nil] options = [req_options: [url: identity.certs], identity: identity, keys: nil]
GenServer.start_link(__MODULE__, options, name: opts[:name]) GenServer.start_link(__MODULE__, options, Keyword.take(opts, [:name]))
end end
def authenticate(name, conn, fields: fields) do def authenticate(name, conn, _opts) do
token = get_req_header(conn, @assertion) token = get_req_header(conn, @assertion)
{identity, keys} = GenServer.call(name, :info, :infinity) {identity, keys} = GenServer.call(name, :info, :infinity)
{conn, authenticate_user(token, fields, identity, keys)} {conn, authenticate_user(token, identity, keys)}
end end
@impl true @impl true
@ -44,7 +44,7 @@ defmodule Livebook.ZTA.Cloudflare do
keys keys
end end
defp authenticate_user(token, _fields, identity, keys) do defp authenticate_user(token, identity, keys) do
with [encoded_token] <- token, with [encoded_token] <- token,
{:ok, token} <- verify_token(encoded_token, keys), {:ok, token} <- verify_token(encoded_token, keys),
:ok <- verify_iss(token, identity.iss), :ok <- verify_iss(token, identity.iss),

View file

@ -7,18 +7,18 @@ defmodule Livebook.ZTA.GoogleIAP do
@renew_afer 24 * 60 * 60 * 1000 @renew_afer 24 * 60 * 60 * 1000
@fields %{"sub" => :id, "name" => :name, "email" => :email} @fields %{"sub" => :id, "name" => :name, "email" => :email}
defstruct [:name, :req_options, :identity, :keys] defstruct [:req_options, :identity, :keys]
def start_link(opts) do def start_link(opts) do
identity = opts[:custom_identity] || identity(opts[:identity][:key]) identity = opts[:custom_identity] || identity(opts[:identity_key])
options = [req_options: [url: identity.certs], identity: identity, keys: nil] options = [req_options: [url: identity.certs], identity: identity, keys: nil]
GenServer.start_link(__MODULE__, options, name: opts[:name]) GenServer.start_link(__MODULE__, options, Keyword.take(opts, [:name]))
end end
def authenticate(name, conn, fields: fields) do def authenticate(name, conn, _opts) do
token = get_req_header(conn, @assertion) token = get_req_header(conn, @assertion)
{identity, keys} = GenServer.call(name, :info, :infinity) {identity, keys} = GenServer.call(name, :info, :infinity)
{conn, authenticate_user(token, fields, identity, keys)} {conn, authenticate_user(token, identity, keys)}
end end
@impl true @impl true
@ -44,7 +44,7 @@ defmodule Livebook.ZTA.GoogleIAP do
keys keys
end end
defp authenticate_user(token, _fields, identity, keys) do defp authenticate_user(token, identity, keys) do
with [encoded_token] <- token, with [encoded_token] <- token,
{:ok, token} <- verify_token(encoded_token, keys), {:ok, token} <- verify_token(encoded_token, keys),
:ok <- verify_iss(token, identity.iss, identity.key) do :ok <- verify_iss(token, identity.iss, identity.key) do

View file

@ -2,14 +2,14 @@ defmodule Livebook.ZTA.Tailscale do
use GenServer use GenServer
require Logger require Logger
defstruct [:name, :address] defstruct [:address]
def start_link(opts) do def start_link(opts) do
options = [address: opts[:identity][:key]] options = [address: opts[:identity_key]]
GenServer.start_link(__MODULE__, options, name: opts[:name]) GenServer.start_link(__MODULE__, options, Keyword.take(opts, [:name]))
end end
def authenticate(name, conn, _) do def authenticate(name, conn, _opts) do
remote_ip = to_string(:inet_parse.ntoa(conn.remote_ip)) remote_ip = to_string(:inet_parse.ntoa(conn.remote_ip))
tailscale_address = GenServer.call(name, :get_address) tailscale_address = GenServer.call(name, :get_address)
user = authenticate_ip(remote_ip, tailscale_address) user = authenticate_ip(remote_ip, tailscale_address)

View file

@ -164,7 +164,6 @@ defmodule LivebookWeb.AppHelpers do
end end
@zta_options for provider <- Livebook.Config.identity_providers(), @zta_options for provider <- Livebook.Config.identity_providers(),
not provider.read_only,
do: {provider.name, provider.type} do: {provider.name, provider.type}
defp zta_options(), do: @zta_options defp zta_options(), do: @zta_options

View file

@ -30,10 +30,9 @@ defmodule LivebookWeb.UserPlug do
end end
defp ensure_user_identity(conn) do defp ensure_user_identity(conn) do
{module, _} = Livebook.Config.identity_provider() {_type, module, _key} = Livebook.Config.identity_provider()
{conn, identity_data} = {conn, identity_data} = module.authenticate(LivebookWeb.ZTA, conn, [])
module.authenticate(LivebookWeb.ZTA, conn, fields: [:id, :name, :email])
if identity_data do if identity_data do
put_session(conn, :identity_data, identity_data) put_session(conn, :identity_data, identity_data)

View file

@ -1,7 +1,7 @@
defmodule Livebook.MixProject do defmodule Livebook.MixProject do
use Mix.Project use Mix.Project
@elixir_requirement "~> 1.15.2" @elixir_requirement "~> 1.15.2 or ~> 1.16-dev"
@version "0.12.0-dev" @version "0.12.0-dev"
@description "Automate code & data workflows with interactive notebooks" @description "Automate code & data workflows with interactive notebooks"

View file

@ -1,6 +1,7 @@
defmodule Livebook.ConfigTest do defmodule Livebook.ConfigTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
doctest Livebook.Config
alias Livebook.Config alias Livebook.Config
describe "node!/1" do describe "node!/1" do

View file

@ -30,9 +30,7 @@ defmodule Livebook.ZTA.TailscaleTest do
options = [ options = [
name: @name, name: @name,
identity: [ identity_key: "http://localhost:#{bypass.port}"
key: "http://localhost:#{bypass.port}"
]
] ]
{:ok, bypass: bypass, options: options, conn: conn} {:ok, bypass: bypass, options: options, conn: conn}
@ -57,7 +55,7 @@ defmodule Livebook.ZTA.TailscaleTest do
end end
socket = Path.relative_to_cwd("#{tmp_dir}/bandit.sock") socket = Path.relative_to_cwd("#{tmp_dir}/bandit.sock")
options = Keyword.put(options, :identity, key: socket) options = Keyword.put(options, :identity_key, socket)
start_supervised!({Bandit, plug: TestPlug, ip: {:local, socket}, port: 0}) start_supervised!({Bandit, plug: TestPlug, ip: {:local, socket}, port: 0})
start_supervised!({Tailscale, options}) start_supervised!({Tailscale, options})
{_conn, user} = Tailscale.authenticate(@name, conn, @fields) {_conn, user} = Tailscale.authenticate(@name, conn, @fields)
@ -66,7 +64,7 @@ defmodule Livebook.ZTA.TailscaleTest do
test "raises when configured with missing unix socket", %{options: options} do test "raises when configured with missing unix socket", %{options: options} do
Process.flag(:trap_exit, true) Process.flag(:trap_exit, true)
options = Keyword.put(options, :identity, key: "./invalid-socket.sock") options = Keyword.put(options, :identity_key, "./invalid-socket.sock")
assert ExUnit.CaptureLog.capture_log(fn -> assert ExUnit.CaptureLog.capture_log(fn ->
{:error, _} = start_supervised({Tailscale, options}) {:error, _} = start_supervised({Tailscale, options})
@ -92,7 +90,7 @@ defmodule Livebook.ZTA.TailscaleTest do
options: options, options: options,
conn: conn conn: conn
} do } do
options = Keyword.put(options, :identity, key: "http://:foobar@localhost:#{bypass.port}") options = Keyword.put(options, :identity_key, "http://:foobar@localhost:#{bypass.port}")
Bypass.expect_once(bypass, fn conn -> Bypass.expect_once(bypass, fn conn ->
assert %{"addr" => "151.236.219.228:1"} = conn.query_params assert %{"addr" => "151.236.219.228:1"} = conn.query_params