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
This commit is contained in:
Jamil
2025-05-16 12:26:08 -07:00
committed by GitHub
parent 9951e82727
commit 65c58ee254
18 changed files with 494 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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"
]}

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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: [

View File

@@ -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,