Repository: firezone/firezone
Pull request #9144: feat(portal): Zero-click client authentication
Author: login: jamilbk
Created: 2025-05-14T19:55:57Z | Merged: 2025-05-16T19:40:00Z

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




Diff:
diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex
index b10b9075d22..69b3f6b16f2 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 c8d247c4037..767eac7ee34 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 957379b7dc3..258a6746218 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 be1ec4b89b2..8cb69fd6b16 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 00000000000..e0264bd5493
--- /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 bc3518a2db6..bb6e502b3ed 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 f9794cf57a2..51a772a1b11 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 5ebab7f71a0..42492d39ead 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
     </div>
     """
   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
+    </.badge>
+    """
+  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 4510b8f23b9..832fd63c29e 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</:label>
-              <:value>{@provider.name}</:value>
+              <:value>
+                {@provider.name}
+                <.assigned_default_badge provider={@provider} />
+              </:value>
             </.vertical_table_row>
             <.vertical_table_row>
               <:label>Status</:label>
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 d7b5991b5df..9140ba932fd 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} />
 
+        <div class="pb-8 px-1">
+          <div class="text-lg text-neutral-600 mb-4">
+            Default Authentication Provider
+          </div>
+          <.default_provider_form
+            providers={@providers}
+            default_provider_changed={@default_provider_changed}
+          />
+        </div>
+
+        <div class="text-lg text-neutral-600 mb-4 px-1">
+          All identity providers
+        </div>
         <.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}
             </.link>
+            <.assigned_default_badge provider={provider} />
           </:col>
           <: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}
+    >
+      <div class="flex gap-2 items-center">
+        <div class="w-32">
+          <.input
+            id="default-provider-select"
+            name="provider_id"
+            type="select"
+            options={@options}
+            value={@value}
+          />
+        </div>
+        <.submit_button
+          phx-disable-with="Saving..."
+          {if @default_provider_changed, do: [], else: [disabled: true, style: "disabled"]}
+          icon="hero-identification"
+        >
+          Make Default
+        </.submit_button>
+      </div>
+      <p class="text-xs text-neutral-500 mt-2">
+        When selected, users signing in from the Firezone client will be taken directly to this provider for authentication.
+      </p>
+    </.form>
+    """
+  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 e5947f32e95..f3336afb42b 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</:label>
-              <:value>{@provider.name}</:value>
+              <:value>
+                {@provider.name}
+                <.assigned_default_badge provider={@provider} />
+              </:value>
             </.vertical_table_row>
             <.vertical_table_row>
               <:label>Status</:label>
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 7b91379bdbb..b9c64cc7159 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</:label>
-              <:value>{@provider.name}</:value>
+              <:value>
+                {@provider.name}
+                <.assigned_default_badge provider={@provider} />
+              </:value>
             </.vertical_table_row>
             <.vertical_table_row>
               <:label>Status</:label>
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 1d29138bb2c..2fd372cd365 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</:label>
-              <:value>{@provider.name}</:value>
+              <:value>
+                {@provider.name}
+                <.assigned_default_badge provider={@provider} />
+              </:value>
             </.vertical_table_row>
             <.vertical_table_row>
               <:label>Description</:label>
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 4a5604f097c..fe5eaa39ee5 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</:label>
-              <:value>{@provider.name}</:value>
+              <:value>
+                {@provider.name}
+                <.assigned_default_badge provider={@provider} />
+              </:value>
             </.vertical_table_row>
             <.vertical_table_row>
               <:label>Status</:label>
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 7aebbcf544d..a02945b6bd2 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</:label>
-              <:value>{@provider.name}</:value>
+              <:value>
+                {@provider.name}
+                <.assigned_default_badge provider={@provider} />
+              </:value>
             </.vertical_table_row>
             <.vertical_table_row>
               <:label>Status</:label>
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 00000000000..37c21e4adc2
--- /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 d76605ca6e6..d94eea36840 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 f14fbe737d4..bd1cb349d17 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,


Reviews:
user: login: jamilbk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T06:00:20Z
user: login: bmanifold
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T14:43:43Z
user: login: bmanifold
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T14:45:31Z
user: login: jamilbk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T15:12:16Z
user: login: jamilbk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T16:04:34Z
user: login: bmanifold
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T16:40:16Z
user: login: jamilbk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T17:14:20Z
user: login: jamilbk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T17:15:30Z
user: login: jamilbk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T18:05:05Z
user: login: bmanifold
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T19:26:06Z

Review comments:
diff_hunk: @@ -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",
path: elixir/apps/web/lib/web/components/form_components.ex
user: login: jamilbk
body: This was adding useless padding to the select element and seems to be from copy-pasta.
created_at: 2025-05-15T06:00:19Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, {:ok, default_provider}} =
+        Repo.transaction(fn ->
+          # 1. Clear default for all other providers
+          Provider.Query.not_disabled()
+          |> Provider.Query.by_account_id(provider.account_id)
path: elixir/apps/domain/lib/domain/auth.ex
user: login: bmanifold
body: For safe measure, rather than:
```
|> Provider.Query.by_account_id(provider.account_id)
```

shouldn't we use:
```
|> Authorizer.for_subject(Provider, subject)
```

In theory, if you use `Provider.query.by_account_id` and a `subject` from another account is passed in, I believe this would still clear the default provider for the account the provider is in.
created_at: 2025-05-16T14:43:43Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, {:ok, default_provider}} =
+        Repo.transaction(fn ->
+          # 1. Clear default for all other providers
+          Provider.Query.not_disabled()
+          |> Provider.Query.by_account_id(provider.account_id)
+          |> Repo.update_all(set: [assigned_default_at: nil])
+
+          # 2. Set default for the given 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
+          )
+        end)
+
+      {:ok, default_provider}
+    end
+  end
+
+  def clear_default_provider(%Subject{} = subject) do
+    with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
+      Provider.Query.not_disabled()
+      |> Provider.Query.by_account_id(subject.account.id)
path: elixir/apps/domain/lib/domain/auth.ex
user: login: bmanifold
body: If you agree on my comment above then the same would apply here.
created_at: 2025-05-16T14:45:30Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, {:ok, default_provider}} =
+        Repo.transaction(fn ->
+          # 1. Clear default for all other providers
+          Provider.Query.not_disabled()
+          |> Provider.Query.by_account_id(provider.account_id)
path: elixir/apps/domain/lib/domain/auth.ex
user: login: jamilbk
body: Good catch, I'll add a test for that.
created_at: 2025-05-16T15:12:16Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, {:ok, default_provider}} =
+        Repo.transaction(fn ->
+          # 1. Clear default for all other providers
+          Provider.Query.not_disabled()
+          |> Provider.Query.by_account_id(provider.account_id)
path: elixir/apps/domain/lib/domain/auth.ex
user: login: jamilbk
body: Fixed
created_at: 2025-05-16T16:04:34Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, result} =
path: elixir/apps/domain/lib/domain/auth.ex
user: login: bmanifold
body: Just for my own edification, is there a reason we're matching on this and not just letting `Repo.transaction` return directly?  Was just wondering what would happen if the transaction fails?

created_at: 2025-05-16T16:40:16Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, result} =
path: elixir/apps/domain/lib/domain/auth.ex
user: login: jamilbk
body: The transaction wraps the result in `{:ok, _}` so we need to unwrap it, otherwise we have `{:ok, {:ok, provider}}`
created_at: 2025-05-16T17:14:20Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, result} =
path: elixir/apps/domain/lib/domain/auth.ex
user: login: jamilbk
body: Hm, I could unwrap the error case too.
created_at: 2025-05-16T17:15:29Z
author_association: MEMBER
diff_hunk: @@ -252,6 +260,37 @@ 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
+      {:ok, result} =
path: elixir/apps/domain/lib/domain/auth.ex
user: login: jamilbk
body: Ok I updated this to follow the convention we have elsewhere. The semantics of `transaction/2` are such that it expects an exception to be raised to trigger the rollback, so I've added an explicit matches inside the transaction to trigger `MatchError` if something fails.

If that happens, the process will crash, which is ok I think given the low likelihood of this happening.

If we want better than that, we need to use `Ecto.Multi` and handle each of the two error cases for the two queries individually in the caller, which I feel is a bit overengineered for this feature.
created_at: 2025-05-16T18:05:05Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-05-14T19:56:02Z
body: [vc]: #JHuFn1mCtrBF9YzJqZ7sQv6np8C+T56yjJd+Jm/ZUfA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJmaXJlem9uZSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJmaXJlem9uZS1naXQtZmVhdC1yZWRpcmVjdC10by1vbmx5LWF1dGgtcHJvdmlkZXItZmlyZXpvbmUudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vZmlyZXpvbmUvZmlyZXpvbmUvQTlzS29Ec1JFSnU0cTJqOUhGVjY1aDlrcU5iNyIsInByZXZpZXdVcmwiOiJmaXJlem9uZS1naXQtZmVhdC1yZWRpcmVjdC10by1vbmx5LWF1dGgtcHJvdmlkZXItZmlyZXpvbmUudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsInJvb3REaXJlY3RvcnkiOiJ3ZWJzaXRlIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **firezone** | ✅ Ready ([Inspect](https://vercel.com/firezone/firezone/A9sKoDsREJu4q2j9HFV65h9kqNb7)) | [Visit Preview](https://firezone-git-feat-redirect-to-only-auth-provider-firezone.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/firezone-git-feat-redirect-to-only-auth-provider-firezone.vercel.app?via=pr-comment-feedback-link) | May 16, 2025 6:03pm |


author_association: NONE
user: login: jamilbk
created_at: 2025-05-14T23:58:40Z
body: @bmanifold I need to write tests, but otherwise this is pretty much done.

I opted not to add magic that selects this by default when creating a provider, due to the principle of least surprise. What we *could* do is automatically redirect the user to the one active provider in the account if the admin has disabled the email/otp provider.
author_association: MEMBER
user: login: bmanifold
created_at: 2025-05-15T05:43:31Z
body: > I opted not to add magic that selects this by default when creating a provider, due to the principle of least surprise. What we could do is automatically redirect the user to the one active provider in the account if the admin has disabled the email/otp provider.

Not having it turned on my default makes a lot of sense to me and would be my preference.

As for turning it on if the account has one active provider and has disabled the email/otp provider, I guess we could, but honestly I feel like we should let someone request this before we spend the time on it.  There are only a handful of accounts that fall in to this bucket today anyway, so it likely won't be used all that much even if we did implement it right now.
author_association: MEMBER
user: login: jamilbk
created_at: 2025-05-15T20:26:41Z
body: @bmanifold I think this is ready to go.
author_association: MEMBER
user: login: bmanifold
created_at: 2025-05-16T15:29:43Z
body: Just wanted to confirm, clients aren't going to get pushed in to a `logout` -> `automatic login` cycle correct?
author_association: MEMBER
user: login: jamilbk
created_at: 2025-05-16T15:32:00Z
body: > Just wanted to confirm, clients aren't going to get pushed in to a `logout` -> `automatic login` cycle correct?

Yeah I don't see how that'd be possible since Signing out on a click doesn't open a web browser. That raises a good point though, as we don't do anything to clear the actual IdP / OIDC session. But no cycles will occur.
author_association: MEMBER
user: login: bmanifold
created_at: 2025-05-16T15:46:29Z
body: > Signing out on a click doesn't open a web browser

But we do send a request to revoke the Firezone session?

> we don't do anything to clear the actual IdP / OIDC session

We shouldn't be doing anything with the IdP/OIDC session, because we'd be causing them to then sign in again for other apps they use the IdP.  We just need to make sure we're revoking the Firezone session.

author_association: MEMBER
user: login: jamilbk
created_at: 2025-05-16T16:00:17Z
body: > But we do send a request to revoke the Firezone session?

No we just drop the token client-side. Would it make sense you think to send a control plane request to sign out as well, invalidating the token?

> We shouldn't be doing anything with the IdP/OIDC session, because we'd be causing them to then sign in again for other apps they use the IdP. We just need to make sure we're revoking the Firezone session.

Hm ok, I seem to remember back in the legacy days that we [redirect the user](https://openid.net/specs/openid-connect-session-1_0-17.html#toc) to the `end_session_uri` with the `id_token_hint` and the provider would only terminate the user's session for Firezone. Seems like this is common for most providers but it depends on the SLO (single logout) configuration on the provider?
author_association: MEMBER
user: login: bmanifold
created_at: 2025-05-16T16:33:36Z
body: > Would it make sense you think to send a control plane request to sign out as well, invalidating the token?

From a security stance it would definitely be preferable to invalidate the session on the server side.  In practice I don't think it's likely someone could get ahold of the session token and continue to use, but in theory it would be possible.

> Hm ok, I seem to remember back in the legacy days that we [redirect the user](https://openid.net/specs/openid-connect-session-1_0-17.html#toc) to the end_session_uri with the id_token_hint and the provider would only terminate the user's session for Firezone. Seems like this is common for most providers but it depends on the SLO (single logout) configuration on the provider?

Yeah, you're correct, if there is an IdP session per app they've signed in to, then it would make sense to sign them out of that.  What I was thinking was more of the SLO (signle logout).  We don't want to log them out of all other apps when they sign out of Firezone.
author_association: MEMBER