From 65c58ee2540087804cf3ed6772cc3ebb75aeeecf Mon Sep 17 00:00:00 2001 From: Jamil Date: Fri, 16 May 2025 12:26:08 -0700 Subject: [PATCH] feat(portal): Zero-click client authentication (#9144) Adds a new field to `settings/identity_providers` that allows an Admin to designate any non-email/otp provider as the `default` for client authentication. Clients will then navigate directly to the provider's `/redirect` endpoint when authenticating, which in many cases will automatically sign them in. No existing providers are updated in this PR. https://github.com/user-attachments/assets/7b962a25-76fd-491f-a194-60ed993821fc --- elixir/apps/domain/lib/domain/auth.ex | 40 +++++ .../apps/domain/lib/domain/auth/provider.ex | 1 + .../lib/domain/auth/provider/changeset.ex | 12 +- .../domain/lib/domain/auth/provider/query.ex | 4 + ...13211142_add_default_to_auth_providers.exs | 35 +++++ elixir/apps/domain/test/domain/auth_test.exs | 111 ++++++++++++++ .../web/lib/web/components/form_components.ex | 2 +- .../settings/identity_providers/components.ex | 14 ++ .../google_workspace/show.ex | 5 +- .../live/settings/identity_providers/index.ex | 138 ++++++++++++++++++ .../identity_providers/jumpcloud/show.ex | 5 +- .../microsoft_entra/show.ex | 5 +- .../settings/identity_providers/mock/show.ex | 5 +- .../settings/identity_providers/okta/show.ex | 5 +- .../identity_providers/openid_connect/show.ex | 5 +- .../plugs/auto_redirect_default_provider.ex | 35 +++++ elixir/apps/web/lib/web/router.ex | 7 +- .../identity_providers/index_test.exs | 74 ++++++++++ 18 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 elixir/apps/domain/priv/repo/migrations/20250513211142_add_default_to_auth_providers.exs create mode 100644 elixir/apps/web/lib/web/plugs/auto_redirect_default_provider.ex diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index b10b9075d..69b3f6b16 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -152,6 +152,14 @@ defmodule Domain.Auth do end end + # Used during client auth + def fetch_default_provider_for_account(%Accounts.Account{} = account, opts \\ []) do + Provider.Query.not_disabled() + |> Provider.Query.by_account_id(account.id) + |> Provider.Query.assigned_default() + |> Repo.fetch(Provider.Query, opts) + end + def list_providers(%Subject{} = subject, opts \\ []) do with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do Provider.Query.not_deleted() @@ -252,6 +260,38 @@ defmodule Domain.Auth do end) end + # Update default provider for client auth + def assign_default_provider(%Provider{} = provider, %Subject{} = subject) do + with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do + Repo.transaction(fn -> + # 1. Clear default for all other providers + {_count, nil} = + Provider.Query.not_disabled() + |> Authorizer.for_subject(Provider, subject) + |> Repo.update_all(set: [assigned_default_at: nil]) + + # 2. Set default for the given provider + {:ok, provider} = + Provider.Query.not_disabled() + |> Provider.Query.by_id(provider.id) + |> Authorizer.for_subject(Provider, subject) + |> Repo.fetch_and_update(Provider.Query, + with: &Provider.Changeset.assign_default_provider/1 + ) + + provider + end) + end + end + + def clear_default_provider(%Subject{} = subject) do + with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do + Provider.Query.not_disabled() + |> Authorizer.for_subject(Provider, subject) + |> Repo.update_all(set: [assigned_default_at: nil]) + end + end + def enable_provider(%Provider{} = provider, %Subject{} = subject) do mutate_provider(provider, subject, &Provider.Changeset.enable_provider/1) end diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index c8d247c40..767eac7ee 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -28,6 +28,7 @@ defmodule Domain.Auth.Provider do field :disabled_at, :utc_datetime_usec field :deleted_at, :utc_datetime_usec + field :assigned_default_at, :utc_datetime_usec timestamps() end end diff --git a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex index 957379b7d..258a67462 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex @@ -3,7 +3,7 @@ defmodule Domain.Auth.Provider.Changeset do alias Domain.Accounts alias Domain.Auth.{Subject, Provider, Adapters} - @create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at]a + @create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at assigned_default_at]a @update_fields ~w[name adapter_config last_syncs_failed last_sync_error sync_disabled_at sync_error_emailed_at adapter_state provisioner disabled_at deleted_at]a @@ -43,6 +43,12 @@ defmodule Domain.Auth.Provider.Changeset do |> changeset() end + def assign_default_provider(%Provider{} = provider) do + provider + |> change() + |> put_change(:assigned_default_at, DateTime.utc_now()) + end + def sync_finished(%Provider{} = provider) do provider |> change() @@ -89,6 +95,10 @@ defmodule Domain.Auth.Provider.Changeset do name: :unique_account_adapter_index, message: "only one of this adapter type may be enabled per account" ) + |> unique_constraint(:base, + name: :auth_providers_account_id_assigned_default_at_index, + message: "only one provider may be assigned default for client authentication" + ) |> validate_provisioner() |> validate_required(@required_fields) end diff --git a/elixir/apps/domain/lib/domain/auth/provider/query.ex b/elixir/apps/domain/lib/domain/auth/provider/query.ex index be1ec4b89..8cb69fd6b 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/query.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/query.ex @@ -14,6 +14,10 @@ defmodule Domain.Auth.Provider.Query do where(queryable, [providers: providers], is_nil(providers.disabled_at)) end + def assigned_default(queryable) do + where(queryable, [providers: providers], not is_nil(providers.assigned_default_at)) + end + def by_id(queryable, id) def by_id(queryable, {:not, id}) do diff --git a/elixir/apps/domain/priv/repo/migrations/20250513211142_add_default_to_auth_providers.exs b/elixir/apps/domain/priv/repo/migrations/20250513211142_add_default_to_auth_providers.exs new file mode 100644 index 000000000..e0264bd54 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250513211142_add_default_to_auth_providers.exs @@ -0,0 +1,35 @@ +defmodule Domain.Repo.Migrations.AddDefaultToAuthProviders do + use Ecto.Migration + + @disable_ddl_transaction true + + def up do + alter table(:auth_providers) do + add(:assigned_default_at, :utc_datetime_usec) + end + + create( + index(:auth_providers, :account_id, + name: :auth_providers_account_id_assigned_default_at_index, + unique: true, + where: "deleted_at IS NULL AND disabled_at IS NULL AND assigned_default_at IS NOT NULL", + concurrently: true + ) + ) + end + + def down do + drop( + index(:auth_providers, :account_id, + name: :auth_providers_account_id_assigned_default_at_index, + unique: true, + where: "deleted_at IS NULL AND disabled_at IS NULL AND assigned_default_at IS NOT NULL", + concurrently: true + ) + ) + + alter table(:auth_providers) do + remove(:assigned_default_at) + end + end +end diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index bc3518a2d..bb6e502b3 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -217,6 +217,117 @@ defmodule Domain.AuthTest do end end + describe "fetch_default_provider_for_account/2" do + test "returns not found if no default providers exist" do + account = Fixtures.Accounts.create_account() + assert fetch_default_provider_for_account(account) == {:error, :not_found} + end + + test "returns default provider for account" do + account = Fixtures.Accounts.create_account() + + {provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider( + account: account, + assigned_default_at: DateTime.utc_now() + ) + + assert {:ok, fetched_provider} = fetch_default_provider_for_account(account) + assert fetched_provider.id == provider.id + end + end + + describe "assign_default_provider/2" do + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + test "assigns default provider for account", %{account: account, subject: subject} do + {provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + assert {:ok, provider} = assign_default_provider(provider, subject) + assert provider.assigned_default_at + end + + test "clears default from all other providers in same account", %{ + account: account, + subject: subject + } do + {provider1, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider( + account: account, + assigned_default_at: DateTime.utc_now() + ) + + {provider2, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + assert {:ok, provider} = assign_default_provider(provider2, subject) + assert provider.assigned_default_at + + assert provider1 = Repo.reload(provider1) + assert is_nil(provider1.assigned_default_at) + end + + test "prevents clearing default from other accounts' providers", %{ + subject: subject + } do + other_account = Fixtures.Accounts.create_account() + + {other_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider( + account: other_account, + assigned_default_at: DateTime.utc_now() + ) + + assert_raise MatchError, fn -> + assign_default_provider(other_provider, subject) + end + end + end + + describe "clear_default_provider/1" do + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + test "clears default provider from account", %{account: account, subject: subject} do + {provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider( + account: account, + assigned_default_at: DateTime.utc_now() + ) + + assert {:ok, default_provider} = fetch_default_provider_for_account(account) + assert provider.id == default_provider.id + + assert {_count, nil} = clear_default_provider(subject) + provider = Repo.reload(provider) + assert is_nil(provider.assigned_default_at) + end + end + describe "list_providers/2" do test "returns all not soft-deleted providers for a given account" do account = Fixtures.Accounts.create_account() diff --git a/elixir/apps/web/lib/web/components/form_components.ex b/elixir/apps/web/lib/web/components/form_components.ex index f9794cf57..51a772a1b 100644 --- a/elixir/apps/web/lib/web/components/form_components.ex +++ b/elixir/apps/web/lib/web/components/form_components.ex @@ -234,7 +234,7 @@ defmodule Web.FormComponents do class={[ "text-sm bg-neutral-50", "border border-neutral-300 text-neutral-900 rounded", - "block p-2.5", + "block", !@inline_errors && "w-full", @errors != [] && "border-rose-400 focus:border-rose-400" ]} diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex index 5ebab7f71..42492d39e 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex @@ -368,4 +368,18 @@ defmodule Web.Settings.IdentityProviders.Components do """ end + + attr :provider, Domain.Auth.Provider, required: true + + def assigned_default_badge(assigns) do + ~H""" + <.badge + :if={!is_nil(@provider.assigned_default_at)} + title="This provider is the default for client authentication" + class="ml-2" + > + default + + """ + end end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex index 4510b8f23..832fd63c2 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex @@ -154,7 +154,10 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Show do <.vertical_table id="provider"> <.vertical_table_row> <:label>Name - <:value>{@provider.name} + <:value> + {@provider.name} + <.assigned_default_badge provider={@provider} /> + <.vertical_table_row> <:label>Status diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex index d7b5991b5..9140ba932 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex @@ -2,6 +2,7 @@ defmodule Web.Settings.IdentityProviders.Index do use Web, :live_view import Web.Settings.IdentityProviders.Components alias Domain.{Auth, Actors} + require Logger def mount(_params, _session, socket) do with {:ok, identities_count_by_provider_id} <- @@ -11,6 +12,7 @@ defmodule Web.Settings.IdentityProviders.Index do socket = socket |> assign( + default_provider_changed: false, page_title: "Identity Providers", identities_count_by_provider_id: identities_count_by_provider_id, groups_count_by_provider_id: groups_count_by_provider_id @@ -69,6 +71,19 @@ defmodule Web.Settings.IdentityProviders.Index do <:content> <.flash_group flash={@flash} /> +
+
+ Default Authentication Provider +
+ <.default_provider_form + providers={@providers} + default_provider_changed={@default_provider_changed} + /> +
+ +
+ All identity providers +
<.live_table id="providers" rows={@providers} @@ -82,6 +97,7 @@ defmodule Web.Settings.IdentityProviders.Index do <.link navigate={view_provider(@account, provider)} class={[link_style()]}> {provider.name} + <.assigned_default_badge provider={provider} /> <:col :let={provider} label="Type" class="w-2/12"> {adapter_name(provider.adapter)} @@ -115,6 +131,128 @@ defmodule Web.Settings.IdentityProviders.Index do """ end + attr :providers, :list, required: true + attr :default_provider_changed, :boolean, required: true + + defp default_provider_form(assigns) do + options = + assigns.providers + |> Enum.filter(fn provider -> + provider.adapter not in [:email, :userpass] + end) + |> Enum.map(fn provider -> + {provider.name, provider.id} + end) + + options = [{"None", :none} | options] + + value = + assigns.providers + |> Enum.find(%{id: :none}, fn provider -> + !is_nil(provider.assigned_default_at) + end) + |> Map.get(:id) + + assigns = assign(assigns, options: options, value: value) + + ~H""" + <.form + id="default-provider-form" + phx-submit="default_provider_save" + phx-change="default_provider_change" + for={nil} + > +
+
+ <.input + id="default-provider-select" + name="provider_id" + type="select" + options={@options} + value={@value} + /> +
+ <.submit_button + phx-disable-with="Saving..." + {if @default_provider_changed, do: [], else: [disabled: true, style: "disabled"]} + icon="hero-identification" + > + Make Default + +
+

+ When selected, users signing in from the Firezone client will be taken directly to this provider for authentication. +

+ + """ + end + + def handle_event("default_provider_change", _params, socket) do + {:noreply, assign(socket, default_provider_changed: true)} + end + + def handle_event("default_provider_save", %{"provider_id" => provider_id}, socket) do + assign_default_provider(provider_id, socket) + end + def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"], do: handle_live_table_event(event, params, socket) + + # Clear default provider + defp assign_default_provider("none", socket) do + with {_count, nil} <- Auth.clear_default_provider(socket.assigns.subject), + {:ok, providers, _metadata} <- Auth.list_providers(socket.assigns.subject) do + socket = + socket + |> put_flash(:info, "Default authentication provider cleared") + |> assign(default_provider_changed: false, providers: providers) + + {:noreply, socket} + else + error -> + Logger.warning("Failed to clear default auth provider", + error: inspect(error) + ) + + socket = + socket + |> put_flash( + :error, + "Failed to update default auth provider. Contact support if this issue persists." + ) + + {:noreply, socket} + end + end + + defp assign_default_provider(provider_id, socket) do + provider = + socket.assigns.providers + |> Enum.find(fn provider -> provider.id == provider_id end) + + with true <- provider.adapter not in [:email, :userpass], + {:ok, _provider} <- Auth.assign_default_provider(provider, socket.assigns.subject), + {:ok, providers, _metadata} <- Auth.list_providers(socket.assigns.subject) do + socket = + socket + |> put_flash(:info, "Default authentication provider set to #{provider.name}") + |> assign(default_provider_changed: false, providers: providers) + + {:noreply, socket} + else + error -> + Logger.warning("Failed to set default auth provider", + error: inspect(error) + ) + + socket = + socket + |> put_flash( + :error, + "Failed to update default auth provider. Contact support if this issue persists." + ) + + {:noreply, socket} + end + end end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/jumpcloud/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/jumpcloud/show.ex index e5947f32e..f3336afb4 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/jumpcloud/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/jumpcloud/show.ex @@ -154,7 +154,10 @@ defmodule Web.Settings.IdentityProviders.JumpCloud.Show do <.vertical_table id="provider"> <.vertical_table_row> <:label>Name - <:value>{@provider.name} + <:value> + {@provider.name} + <.assigned_default_badge provider={@provider} /> + <.vertical_table_row> <:label>Status diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/show.ex index 7b91379bd..b9c64cc71 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/microsoft_entra/show.ex @@ -152,7 +152,10 @@ defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Show do <.vertical_table id="provider"> <.vertical_table_row> <:label>Name - <:value>{@provider.name} + <:value> + {@provider.name} + <.assigned_default_badge provider={@provider} /> + <.vertical_table_row> <:label>Status diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/mock/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/mock/show.ex index 1d29138bb..2fd372cd3 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/mock/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/mock/show.ex @@ -141,7 +141,10 @@ defmodule Web.Settings.IdentityProviders.Mock.Show do <.vertical_table id="provider"> <.vertical_table_row> <:label>Name - <:value>{@provider.name} + <:value> + {@provider.name} + <.assigned_default_badge provider={@provider} /> + <.vertical_table_row> <:label>Description diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex index 4a5604f09..fe5eaa39e 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex @@ -152,7 +152,10 @@ defmodule Web.Settings.IdentityProviders.Okta.Show do <.vertical_table id="provider"> <.vertical_table_row> <:label>Name - <:value>{@provider.name} + <:value> + {@provider.name} + <.assigned_default_badge provider={@provider} /> + <.vertical_table_row> <:label>Status diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex index 7aebbcf54..a02945b6b 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex @@ -132,7 +132,10 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Show do <.vertical_table id="provider"> <.vertical_table_row> <:label>Name - <:value>{@provider.name} + <:value> + {@provider.name} + <.assigned_default_badge provider={@provider} /> + <.vertical_table_row> <:label>Status diff --git a/elixir/apps/web/lib/web/plugs/auto_redirect_default_provider.ex b/elixir/apps/web/lib/web/plugs/auto_redirect_default_provider.ex new file mode 100644 index 000000000..37c21e4ad --- /dev/null +++ b/elixir/apps/web/lib/web/plugs/auto_redirect_default_provider.ex @@ -0,0 +1,35 @@ +defmodule Web.Plugs.AutoRedirectDefaultProvider do + use Phoenix.VerifiedRoutes, endpoint: Web.Endpoint, router: Web.Router + import Plug.Conn + import Phoenix.Controller, only: [redirect: 2] + alias Domain.Auth + + def init(opts), do: opts + + # client sign in + def call(%{params: %{"as" => "client"}} = conn, _opts) do + with account <- conn.assigns.account, + {:ok, provider} <- Auth.fetch_default_provider_for_account(account) do + redirect_path = ~p"/#{account}/sign_in/providers/#{provider}/redirect" + + # Append original query params + full_redirect_path = + if conn.query_string != "" do + redirect_path <> "?" <> conn.query_string + else + redirect_path + end + + conn + |> redirect(to: full_redirect_path) + |> halt() + else + _ -> conn + end + end + + # Non-client sign in + def call(conn, _opts) do + conn + end +end diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index d76605ca6..d94eea368 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -74,7 +74,12 @@ defmodule Web.Router do end scope "/:account_id_or_slug", Web do - pipe_through [:public, :account, :redirect_if_user_is_authenticated] + pipe_through [ + :public, + :account, + :redirect_if_user_is_authenticated, + Web.Plugs.AutoRedirectDefaultProvider + ] live_session :redirect_if_user_is_authenticated, on_mount: [ diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs index f14fbe737..bd1cb349d 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs @@ -63,6 +63,80 @@ defmodule Web.Live.Settings.IdentityProviders.IndexTest do assert Floki.text(button) =~ "Add Identity Provider" end + test "renders default provider form", %{account: account, identity: identity, conn: conn} do + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers") + + assert Floki.text(html) =~ "Default Authentication Provider" + assert form = Floki.find(html, "form#default-provider-form") + + assert Floki.text(form) =~ + "When selected, users signing in from the Firezone client will be taken directly to this provider for authentication." + end + + test "allows setting a default provider", %{ + account: account, + identity: identity, + conn: conn + } do + {provider, _bypass} = Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + {:ok, lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers") + + assert Floki.text(html) =~ "Default Authentication Provider" + + html = + lv + |> form("#default-provider-form", %{ + "provider_id" => provider.id + }) + |> render_submit() + + # Assert the default provider is set + assert html + |> Floki.find("option[selected]") + |> Floki.attribute("value") == [to_string(provider.id)] + end + + test "allows clearing the default provider", %{ + account: account, + identity: identity, + conn: conn + } do + {provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider( + account: account, + assigned_default_at: DateTime.utc_now() + ) + + {:ok, lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers") + + assert Floki.text(html) =~ "Default Authentication Provider" + + html = + lv + |> form("#default-provider-form", %{ + "provider_id" => "none" + }) + |> render_submit() + + # Assert the default provider is set + assert html + |> Floki.find("option[selected]") + |> Floki.attribute("value") == ["none"] + + provider = Repo.reload(provider) + assert is_nil(provider.assigned_default_at) + end + test "renders table with multiple providers", %{ account: account, openid_connect_provider: openid_connect_provider,