feat(portal): Add Microsoft Entra IDP sync to portal (#3433)

Why:

* To allow syncing of users/groups/memberships from an IDP to Firezone,
a custom identify provider adapter needs to be created in the portal
codebase at this time. The custom IDP adapter created in this commit is
for Microsoft Entra.
This commit is contained in:
Brian Manifold
2024-02-05 10:32:06 -05:00
committed by GitHub
parent b73b0cf2b7
commit ed1ceb7e6e
25 changed files with 1974 additions and 50 deletions

View File

@@ -6,6 +6,7 @@ defmodule Domain.Auth.Adapters do
email: Domain.Auth.Adapters.Email,
openid_connect: Domain.Auth.Adapters.OpenIDConnect,
google_workspace: Domain.Auth.Adapters.GoogleWorkspace,
microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra,
userpass: Domain.Auth.Adapters.UserPass
}

View File

@@ -0,0 +1,44 @@
defmodule Domain.Auth.Adapters.Common.SyncLogger do
require Logger
# Log effects of the multi transaction
def log_effects(provider, effects) do
%{
# Identities
plan_identities: {identities_insert_ids, identities_update_ids, identities_delete_ids},
insert_identities: identities_inserted,
update_identities_and_actors: identities_updated,
delete_identities: identities_deleted,
# Groups
plan_groups: {groups_upsert_ids, groups_delete_ids},
upsert_groups: groups_upserted,
delete_groups: groups_deleted,
# Memberships
plan_memberships: {memberships_insert_tuples, memberships_delete_tuples},
insert_memberships: memberships_inserted,
delete_memberships: {deleted_memberships_count, _}
} = effects
Logger.debug("Finished syncing provider",
provider_id: provider.id,
account_id: provider.account_id,
# Identities
plan_identities_insert: length(identities_insert_ids),
plan_identities_update: length(identities_update_ids),
plan_identities_delete: length(identities_delete_ids),
identities_inserted: length(identities_inserted),
identities_and_actors_updated: length(identities_updated),
identities_deleted: length(identities_deleted),
# Groups
plan_groups_upsert: length(groups_upsert_ids),
plan_groups_delete: length(groups_delete_ids),
groups_upserted: length(groups_upserted),
groups_deleted: length(groups_deleted),
# Memberships
plan_memberships_insert: length(memberships_insert_tuples),
plan_memberships_delete: length(memberships_delete_tuples),
memberships_inserted: length(memberships_inserted),
memberships_deleted: deleted_memberships_count
)
end
end

View File

@@ -2,6 +2,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
use Domain.Jobs.Recurrent, otp_app: :domain
alias Domain.{Auth, Actors}
alias Domain.Auth.Adapters.GoogleWorkspace
alias Domain.Auth.Adapters.Common.SyncLogger
require Logger
every minutes(5), :refresh_access_tokens do
@@ -35,7 +36,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
every minutes(3), :sync_directory do
with {:ok, providers} <- Domain.Auth.list_providers_pending_sync_by_adapter(:google_workspace) do
Logger.debug("Syncing #{length(providers)} providers")
Logger.debug("Syncing #{length(providers)} Google Workspace providers")
providers
|> Enum.chunk_every(5)
@@ -105,44 +106,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
|> Domain.Repo.transaction()
|> case do
{:ok, effects} ->
%{
# Identities
plan_identities:
{identities_insert_ids, identities_update_ids, identities_delete_ids},
insert_identities: identities_inserted,
update_identities_and_actors: identities_updated,
delete_identities: identities_deleted,
# Groups
plan_groups: {groups_upsert_ids, groups_delete_ids},
upsert_groups: groups_upserted,
delete_groups: groups_deleted,
# Memberships
plan_memberships: {memberships_insert_tuples, memberships_delete_tuples},
insert_memberships: memberships_inserted,
delete_memberships: {deleted_memberships_count, _}
} = effects
Logger.debug("Finished syncing provider",
provider_id: provider.id,
account_id: provider.account_id,
# Identities
plan_identities_insert: length(identities_insert_ids),
plan_identities_update: length(identities_update_ids),
plan_identities_delete: length(identities_delete_ids),
identities_inserted: length(identities_inserted),
identities_and_actors_updated: length(identities_updated),
identities_deleted: length(identities_deleted),
# Groups
plan_groups_upsert: length(groups_upsert_ids),
plan_groups_delete: length(groups_delete_ids),
groups_upserted: length(groups_upserted),
groups_deleted: length(groups_deleted),
# Memberships
plan_memberships_insert: length(memberships_insert_tuples),
plan_memberships_delete: length(memberships_delete_tuples),
memberships_inserted: length(memberships_inserted),
memberships_deleted: deleted_memberships_count
)
SyncLogger.log_effects(provider, effects)
{:error, reason} ->
Logger.error("Failed to sync provider",

View File

@@ -0,0 +1,82 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra do
use Supervisor
alias Domain.Actors
alias Domain.Auth.{Provider, Adapter}
alias Domain.Auth.Adapters.OpenIDConnect
alias Domain.Auth.Adapters.MicrosoftEntra
require Logger
@behaviour Adapter
@behaviour Adapter.IdP
def start_link(_init_arg) do
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
MicrosoftEntra.APIClient,
{Domain.Jobs, MicrosoftEntra.Jobs}
]
Supervisor.init(children, strategy: :one_for_one)
end
@impl true
def capabilities do
[
provisioners: [:custom],
default_provisioner: :custom,
parent_adapter: :openid_connect
]
end
@impl true
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
changeset
|> Domain.Validator.trim_change(:provider_identifier)
|> Domain.Validator.copy_change(:provider_virtual_state, :provider_state)
|> Ecto.Changeset.put_change(:provider_virtual_state, %{})
end
@impl true
def provider_changeset(%Ecto.Changeset{} = changeset) do
changeset
|> Domain.Changeset.cast_polymorphic_embed(:adapter_config,
required: true,
with: fn current_attrs, attrs ->
Ecto.embedded_load(MicrosoftEntra.Settings, current_attrs, :json)
|> MicrosoftEntra.Settings.Changeset.changeset(attrs)
end
)
end
@impl true
def ensure_provisioned(%Provider{} = provider) do
{:ok, provider}
end
@impl true
def ensure_deprovisioned(%Provider{} = provider) do
{:ok, provider}
end
@impl true
def sign_out(provider, identity, redirect_url) do
OpenIDConnect.sign_out(provider, identity, redirect_url)
end
@impl true
def verify_and_update_identity(%Provider{} = provider, payload) do
OpenIDConnect.verify_and_update_identity(provider, payload, "oid")
end
def verify_and_upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, payload) do
OpenIDConnect.verify_and_upsert_identity(actor, provider, payload, "oid")
end
def refresh_access_token(%Provider{} = provider) do
OpenIDConnect.refresh_access_token(provider)
end
end

View File

@@ -0,0 +1,157 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra.APIClient do
use Supervisor
@pool_name __MODULE__.Finch
@user_fields ~w[
id
accountEnabled
displayName
givenName
surname
mail
userPrincipalName
]
@group_fields ~w[
id
displayName
]
@group_member_fields ~w[
id
accountEnabled
]
def start_link(_init_arg) do
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{Finch,
name: @pool_name,
pools: %{
default: pool_opts()
}}
]
Supervisor.init(children, strategy: :one_for_one)
end
defp pool_opts do
transport_opts =
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:finch_transport_opts)
[conn_opts: [transport_opts: transport_opts]]
end
def list_users(api_token) do
endpoint =
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:endpoint)
uri =
URI.parse("#{endpoint}/v1.0/users")
|> URI.append_query(
URI.encode_query(%{
"$select" => Enum.join(@user_fields, ","),
"$filter" => "accountEnabled eq true"
})
)
list_all(uri, api_token)
end
def list_groups(api_token) do
endpoint =
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:endpoint)
uri =
URI.parse("#{endpoint}/v1.0/groups")
|> URI.append_query(
URI.encode_query(%{
"$select" => Enum.join(@group_fields, ",")
})
)
list_all(uri, api_token)
end
def list_group_members(api_token, group_id) do
endpoint =
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:endpoint)
# NOTE: In order to enabled the $filter=accountEnabled eq true the
# `ConsistencyLevel` parameter and $count=true are required to be enabled as well.
# The ConsistencyLevel=eventual means that it may take some time before changes in Microsoft Entra
# are reflected in the response, which may be acceptable, but for now we'll manually filter the
# accountEnabled field in the response.
# "$filter" => "accountEnabled eq true",
# "$count" => "true",
# "ConsistencyLevel" => "eventual"
uri =
URI.parse("#{endpoint}/v1.0/groups/#{group_id}/transitiveMembers/microsoft.graph.user")
|> URI.append_query(
URI.encode_query(%{
"$select" => Enum.join(@group_member_fields, ",")
})
)
with {:ok, members} <- list_all(uri, api_token) do
enabled_members =
Enum.filter(members, fn member ->
member["accountEnabled"] == true
end)
{:ok, enabled_members}
end
end
defp list_all(uri, api_token, acc \\ []) do
case list(uri, api_token) do
{:ok, list, nil} ->
{:ok, List.flatten(Enum.reverse([list | acc]))}
{:ok, list, next_page_uri} ->
URI.parse(next_page_uri)
|> list_all(api_token, [list | acc])
{:error, reason} ->
{:error, reason}
end
end
defp list(uri, api_token) do
request = Finch.build(:get, uri, [{"Authorization", "Bearer #{api_token}"}])
with {:ok, %Finch.Response{body: response, status: status}} when status in 200..299 <-
Finch.request(request, @pool_name),
{:ok, json_response} <- Jason.decode(response),
{:ok, list} <- Map.fetch(json_response, "value") do
{:ok, list, json_response["@odata.nextLink"]}
else
{:ok, %Finch.Response{status: status}} when status in 500..599 ->
{:error, :retry_later}
{:ok, %Finch.Response{body: response, status: status}} ->
case Jason.decode(response) do
{:ok, json_response} ->
{:error, {status, json_response}}
_error ->
{:error, {status, response}}
end
:error ->
{:ok, [], nil}
other ->
other
end
end
end

View File

@@ -0,0 +1,170 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do
use Domain.Jobs.Recurrent, otp_app: :domain
alias Domain.{Auth, Actors}
alias Domain.Auth.Adapters.MicrosoftEntra
alias Domain.Auth.Adapters.Common.SyncLogger
require Logger
every minutes(5), :refresh_access_tokens do
with {:ok, providers} <-
Domain.Auth.list_providers_pending_token_refresh_by_adapter(:microsoft_entra) do
Logger.debug("Refreshing access tokens for #{length(providers)} Microsoft Entra providers")
Enum.each(providers, fn provider ->
Logger.debug("Refreshing access token",
provider_id: provider.id,
account_id: provider.account_id
)
case MicrosoftEntra.refresh_access_token(provider) do
{:ok, provider} ->
Logger.debug("Finished refreshing access token",
provider_id: provider.id,
account_id: provider.account_id
)
{:error, reason} ->
Logger.error("Failed refreshing access token",
provider_id: provider.id,
account_id: provider.account_id,
reason: inspect(reason)
)
end
end)
end
end
every minutes(3), :sync_directory do
with {:ok, providers} <- Domain.Auth.list_providers_pending_sync_by_adapter(:microsoft_entra) do
Logger.debug("Syncing #{length(providers)} Microsoft Entra providers")
providers
|> Enum.chunk_every(5)
|> Enum.each(fn providers ->
Enum.map(providers, fn provider ->
sync_provider_directory(provider)
end)
end)
end
end
def sync_provider_directory(provider) do
Logger.debug("Syncing provider: #{provider.id}", provider_id: provider.id)
access_token = provider.adapter_state["access_token"]
with {:ok, users} <- MicrosoftEntra.APIClient.list_users(access_token),
{:ok, groups} <- MicrosoftEntra.APIClient.list_groups(access_token),
{:ok, tuples} <- list_membership_tuples(access_token, groups) do
identities_attrs = map_identity_attrs(users)
actor_groups_attrs = map_group_attrs(groups)
Ecto.Multi.new()
|> Ecto.Multi.append(Auth.sync_provider_identities_multi(provider, identities_attrs))
|> Ecto.Multi.append(Actors.sync_provider_groups_multi(provider, actor_groups_attrs))
|> Actors.sync_provider_memberships_multi(provider, tuples)
|> Ecto.Multi.update(:save_last_updated_at, fn _effects_so_far ->
Auth.Provider.Changeset.sync_finished(provider)
end)
|> Domain.Repo.transaction()
|> case do
{:ok, effects} ->
SyncLogger.log_effects(provider, effects)
{:error, reason} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
reason: inspect(reason)
)
{:error, op, value, changes_so_far} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
op: op,
value: inspect(value),
changes_so_far: inspect(changes_so_far)
)
end
else
{:error, {status, %{"error" => %{"message" => message}}}} ->
provider =
Auth.Provider.Changeset.sync_failed(provider, message)
|> Domain.Repo.update!()
log_sync_error(provider, "Microsoft Graph API returned #{status}: #{message}")
{:error, :retry_later} ->
message = "Microsoft Graph API is temporarily unavailable"
provider =
Auth.Provider.Changeset.sync_failed(provider, message)
|> Domain.Repo.update!()
log_sync_error(provider, message)
{:error, reason} ->
Logger.error("Failed syncing provider",
account_id: provider.account_id,
provider_id: provider.id,
reason: inspect(reason)
)
end
end
defp log_sync_error(provider, message) do
metadata = [
account_id: provider.account_id,
provider_id: provider.id,
reason: message
]
if provider.last_syncs_failed >= 3 do
Logger.warning("Failed syncing provider", metadata)
else
Logger.info("Failed syncing provider", metadata)
end
end
defp list_membership_tuples(access_token, groups) do
Enum.reduce_while(groups, {:ok, []}, fn group, {:ok, tuples} ->
case MicrosoftEntra.APIClient.list_group_members(access_token, group["id"]) do
{:ok, members} ->
tuples = Enum.map(members, &{"G:" <> group["id"], &1["id"]}) ++ tuples
{:cont, {:ok, tuples}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
end
# Map identity attributes from Microsoft Entra to Domain
defp map_identity_attrs(users) do
Enum.map(users, fn user ->
%{
"provider_identifier" => user["id"],
"provider_state" => %{
"userinfo" => %{
"email" => user["userPrincipalName"]
}
},
"actor" => %{
"type" => :account_user,
"name" => user["displayName"]
}
}
end)
end
# Map group attributes from Microsoft Entra to Domain
defp map_group_attrs(groups) do
Enum.map(groups, fn group ->
%{
"name" => "Group:" <> group["displayName"],
"provider_identifier" => "G:" <> group["id"]
}
end)
end
end

View File

@@ -0,0 +1,23 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra.Settings do
use Domain, :schema
@scope ~w[
openid email profile
offline_access
Group.Read.All
GroupMember.Read.All
User.Read
User.Read.All
]
@primary_key false
embedded_schema do
field :scope, :string, default: Enum.join(@scope, " ")
field :response_type, :string, default: "code"
field :client_id, :string
field :client_secret, :string
field :discovery_document_uri, :string
end
def scope, do: @scope
end

View File

@@ -0,0 +1,23 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra.Settings.Changeset do
use Domain, :changeset
alias Domain.Auth.Adapters.MicrosoftEntra.Settings
alias Domain.Auth.Adapters.OpenIDConnect
@fields ~w[scope
response_type
client_id client_secret
discovery_document_uri]a
def changeset(%Settings{} = settings, attrs) do
changeset =
settings
|> cast(attrs, @fields)
|> validate_required(@fields)
|> OpenIDConnect.Settings.Changeset.validate_discovery_document_uri()
|> validate_inclusion(:response_type, ~w[code])
Enum.reduce(Settings.scope(), changeset, fn scope, changeset ->
validate_format(changeset, :scope, ~r/#{scope}/, message: "must include #{scope} scope")
end)
end
end

View File

@@ -107,7 +107,11 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
end
@impl true
def verify_and_update_identity(%Provider{} = provider, {redirect_uri, code_verifier, code}) do
def verify_and_update_identity(
%Provider{} = provider,
{redirect_uri, code_verifier, code},
identifier_claim \\ "sub"
) do
token_params = %{
grant_type: "authorization_code",
redirect_uri: redirect_uri,
@@ -116,7 +120,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
}
with {:ok, provider_identifier, identity_state} <-
fetch_state(provider, token_params) do
fetch_state(provider, token_params, identifier_claim) do
Identity.Query.not_disabled()
|> Identity.Query.by_provider_id(provider.id)
|> Identity.Query.by_provider_claims(
@@ -145,7 +149,8 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
def verify_and_upsert_identity(
%Actors.Actor{} = actor,
%Provider{} = provider,
{redirect_uri, code_verifier, code}
{redirect_uri, code_verifier, code},
identifier_claim \\ "sub"
) do
token_params = %{
grant_type: "authorization_code",
@@ -155,7 +160,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
}
with {:ok, provider_identifier, identity_state} <-
fetch_state(provider, token_params) do
fetch_state(provider, token_params, identifier_claim) do
Domain.Auth.upsert_identity(actor, provider, %{
provider_identifier: provider_identifier,
provider_virtual_state: identity_state
@@ -212,7 +217,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
end
end
defp fetch_state(%Provider{} = provider, token_params) do
defp fetch_state(%Provider{} = provider, token_params, identifier_claim \\ "sub") do
config = config_for_provider(provider)
with {:ok, tokens} <- OpenIDConnect.fetch_tokens(config, token_params),
@@ -230,7 +235,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
nil
end
provider_identifier = claims["sub"]
provider_identifier = claims[identifier_claim]
{:ok, provider_identifier,
%{

View File

@@ -4,7 +4,9 @@ defmodule Domain.Auth.Provider do
schema "auth_providers" do
field :name, :string
field :adapter, Ecto.Enum, values: ~w[email openid_connect google_workspace userpass]a
field :adapter, Ecto.Enum,
values: ~w[email openid_connect google_workspace microsoft_entra userpass]a
field :provisioner, Ecto.Enum, values: ~w[manual just_in_time custom]a
field :adapter_config, :map
field :adapter_state, :map

View File

@@ -446,11 +446,13 @@ defmodule Domain.Config.Definitions do
:auth_provider_adapters,
{:array, ",", {:parameterized, Ecto.Enum, Ecto.Enum.init(values: ~w[
email
openid_connect google_workspace
openid_connect
google_workspace
microsoft_entra
userpass
token
]a)}},
default: ~w[email openid_connect google_workspace token]a
default: ~w[email openid_connect google_workspace microsoft_entra token]a
)
##############################################

View File

@@ -0,0 +1,123 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra.APIClientTest do
use ExUnit.Case, async: true
alias Domain.Mocks.MicrosoftEntraDirectory
import Domain.Auth.Adapters.MicrosoftEntra.APIClient
describe "list_users/1" do
test "returns list of users" do
api_token = Ecto.UUID.generate()
bypass = Bypass.open()
MicrosoftEntraDirectory.mock_users_list_endpoint(bypass)
assert {:ok, users} = list_users(api_token)
assert length(users) == 3
for user <- users do
assert Map.has_key?(user, "id")
# Profile fields
assert Map.has_key?(user, "userPrincipalName")
assert Map.has_key?(user, "displayName")
assert Map.has_key?(user, "givenName")
assert Map.has_key?(user, "surname")
assert Map.has_key?(user, "mail")
assert Map.has_key?(user, "accountEnabled")
end
assert_receive {:bypass_request, conn}
assert conn.params == %{
"$filter" => "accountEnabled eq true",
"$select" =>
Enum.join(
~w[
id
accountEnabled
displayName
givenName
surname
mail
userPrincipalName
],
","
)
}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
end
test "returns error when Microsoft Graph API is down" do
api_token = Ecto.UUID.generate()
bypass = Bypass.open()
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
Bypass.down(bypass)
assert list_users(api_token) == {:error, %Mint.TransportError{reason: :econnrefused}}
end
end
describe "list_groups/1" do
test "returns list of groups" do
api_token = Ecto.UUID.generate()
bypass = Bypass.open()
MicrosoftEntraDirectory.mock_groups_list_endpoint(bypass)
assert {:ok, groups} = list_groups(api_token)
assert length(groups) == 3
for group <- groups do
assert Map.has_key?(group, "id")
assert Map.has_key?(group, "displayName")
end
assert_receive {:bypass_request, conn}
assert conn.params == %{
"$select" => "id,displayName"
}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
end
test "returns error when Microsoft Graph API is down" do
api_token = Ecto.UUID.generate()
bypass = Bypass.open()
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
Bypass.down(bypass)
assert list_groups(api_token) == {:error, %Mint.TransportError{reason: :econnrefused}}
end
end
describe "list_group_members/1" do
test "returns list of group members" do
api_token = Ecto.UUID.generate()
group_id = Ecto.UUID.generate()
bypass = Bypass.open()
MicrosoftEntraDirectory.mock_group_members_list_endpoint(bypass, group_id)
assert {:ok, members} = list_group_members(api_token, group_id)
assert length(members) == 3
for member <- members do
assert Map.has_key?(member, "id")
assert Map.has_key?(member, "accountEnabled")
end
assert_receive {:bypass_request, conn}
assert conn.params == %{"$select" => "id,accountEnabled"}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
end
test "returns error when Microsoft Graph API is down" do
api_token = Ecto.UUID.generate()
group_id = Ecto.UUID.generate()
bypass = Bypass.open()
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
Bypass.down(bypass)
assert list_group_members(api_token, group_id) ==
{:error, %Mint.TransportError{reason: :econnrefused}}
end
end
end

View File

@@ -0,0 +1,510 @@
defmodule Domain.Auth.Adapters.MicrosoftEntra.JobsTest do
use Domain.DataCase, async: true
alias Domain.{Auth, Actors}
alias Domain.Mocks.MicrosoftEntraDirectory
import Domain.Auth.Adapters.MicrosoftEntra.Jobs
describe "refresh_access_tokens/1" do
setup do
account = Fixtures.Accounts.create_account()
{provider, bypass} =
Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account)
provider =
Domain.Fixture.update!(provider, %{
adapter_state: %{
"access_token" => "OIDC_ACCESS_TOKEN",
"refresh_token" => "OIDC_REFRESH_TOKEN",
"expires_at" => DateTime.utc_now() |> DateTime.add(15, :minute),
"claims" => "openid email profile offline_access"
}
})
identity = Fixtures.Auth.create_identity(account: account, provider: provider)
%{
bypass: bypass,
account: account,
provider: provider,
identity: identity
}
end
test "refreshes the access token", %{
provider: provider,
identity: identity,
bypass: bypass
} do
{token, claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity)
Mocks.OpenIDConnect.expect_refresh_token(bypass, %{
"token_type" => "Bearer",
"id_token" => token,
"access_token" => "MY_ACCESS_TOKEN",
"refresh_token" => "OTHER_REFRESH_TOKEN",
"expires_in" => nil
})
Mocks.OpenIDConnect.expect_userinfo(bypass)
assert refresh_access_tokens(%{}) == :ok
provider = Repo.get!(Domain.Auth.Provider, provider.id)
assert %{
"access_token" => "MY_ACCESS_TOKEN",
"claims" => ^claims,
"expires_at" => expires_at,
"refresh_token" => "OIDC_REFRESH_TOKEN",
"userinfo" => %{
"email" => "ada@example.com",
"email_verified" => true,
"family_name" => "Lovelace",
"given_name" => "Ada",
"locale" => "en",
"name" => "Ada Lovelace",
"picture" =>
"https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg",
"sub" => "353690423699814251281"
}
} = provider.adapter_state
assert expires_at
end
test "does not crash when endpoint it not available", %{
bypass: bypass
} do
Bypass.down(bypass)
assert refresh_access_tokens(%{}) == :ok
end
end
describe "sync_directory/1" do
setup do
account = Fixtures.Accounts.create_account()
{provider, bypass} =
Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account)
%{
bypass: bypass,
account: account,
provider: provider
}
end
test "syncs IdP data", %{provider: provider} do
bypass = Bypass.open()
groups = [
%{"id" => "GROUP_ALL_ID", "displayName" => "All"},
%{"id" => "GROUP_ENGINEERING_ID", "displayName" => "Engineering"}
]
users = [
%{
"id" => "USER_JDOE_ID",
"displayName" => "John Doe",
"givenName" => "John",
"surname" => "Doe",
"userPrincipalName" => "jdoe@example.local",
"mail" => "jdoe@example.local",
"accountEnabled" => true
},
%{
"id" => "USER_JSMITH_ID",
"displayName" => "Jane Smith",
"givenName" => "Jane",
"surname" => "Smith",
"userPrincipalName" => "jsmith@example.local",
"mail" => "jsmith@example.local",
"accountEnabled" => true
}
]
members = [
%{
"id" => "USER_JDOE_ID",
"displayName" => "John Doe",
"userPrincipalName" => "jdoe@example.local",
"accountEnabled" => true
},
%{
"id" => "USER_JSMITH_ID",
"displayName" => "Jane Smith",
"userPrincipalName" => "jsmith@example.local",
"accountEnabled" => true
}
]
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
MicrosoftEntraDirectory.mock_groups_list_endpoint(bypass, groups)
MicrosoftEntraDirectory.mock_users_list_endpoint(bypass, users)
Enum.each(groups, fn group ->
MicrosoftEntraDirectory.mock_group_members_list_endpoint(bypass, group["id"], members)
end)
assert sync_directory(%{}) == :ok
groups = Actors.Group |> Repo.all()
assert length(groups) == 2
for group <- groups do
assert group.provider_identifier in ["G:GROUP_ALL_ID", "G:GROUP_ENGINEERING_ID"]
assert group.name in ["Group:All", "Group:Engineering"]
assert group.inserted_at
assert group.updated_at
assert group.created_by == :provider
assert group.provider_id == provider.id
end
identities = Auth.Identity |> Repo.all() |> Repo.preload(:actor)
assert length(identities) == 2
for identity <- identities do
assert identity.inserted_at
assert identity.created_by == :provider
assert identity.provider_id == provider.id
assert identity.provider_identifier in ["USER_JDOE_ID", "USER_JSMITH_ID"]
assert identity.provider_state in [
%{"userinfo" => %{"email" => "jdoe@example.local"}},
%{"userinfo" => %{"email" => "jsmith@example.local"}}
]
assert identity.actor.name in ["John Doe", "Jane Smith"]
assert identity.actor.last_synced_at
end
memberships = Actors.Membership |> Repo.all()
assert length(memberships) == 4
updated_provider = Repo.get!(Domain.Auth.Provider, provider.id)
assert updated_provider.last_synced_at != provider.last_synced_at
end
test "does not crash on endpoint errors" do
bypass = Bypass.open()
Bypass.down(bypass)
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
assert sync_directory(%{}) == :ok
assert Repo.aggregate(Actors.Group, :count) == 0
end
test "updates existing identities and actors", %{account: account, provider: provider} do
bypass = Bypass.open()
users = [
%{
"id" => "USER_JDOE_ID",
"displayName" => "John Doe",
"givenName" => "John",
"surname" => "Doe",
"userPrincipalName" => "jdoe@example.local",
"mail" => "jdoe@example.local",
"accountEnabled" => true
}
]
actor = Fixtures.Actors.create_actor(account: account)
identity =
Fixtures.Auth.create_identity(
account: account,
provider: provider,
actor: actor,
provider_identifier: "USER_JDOE_ID"
)
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
MicrosoftEntraDirectory.mock_groups_list_endpoint(bypass, [])
MicrosoftEntraDirectory.mock_users_list_endpoint(bypass, users)
assert sync_directory(%{}) == :ok
assert updated_identity =
Repo.get(Domain.Auth.Identity, identity.id)
|> Repo.preload(:actor)
assert updated_identity.provider_state == %{
"userinfo" => %{"email" => "jdoe@example.local"}
}
assert updated_identity.actor.name == "John Doe"
assert updated_identity.actor.last_synced_at
end
test "updates existing groups and memberships", %{account: account, provider: provider} do
bypass = Bypass.open()
users = [
%{
"id" => "USER_JDOE_ID",
"displayName" => "John Doe",
"givenName" => "John",
"surname" => "Doe",
"userPrincipalName" => "jdoe@example.local",
"mail" => "jdoe@example.local",
"accountEnabled" => true
},
%{
"id" => "USER_JSMITH_ID",
"displayName" => "Jane Smith",
"givenName" => "Jane",
"surname" => "Smith",
"userPrincipalName" => "jsmith@example.local",
"mail" => "jsmith@example.local",
"accountEnabled" => true
}
]
groups = [
%{"id" => "GROUP_ALL_ID", "displayName" => "All"},
%{"id" => "GROUP_ENGINEERING_ID", "displayName" => "Engineering"}
]
one_member = [
%{
"id" => "USER_JDOE_ID",
"displayName" => "John Doe",
"userPrincipalName" => "jdoe@example.local",
"accountEnabled" => true
}
]
two_members =
one_member ++
[
%{
"id" => "USER_JSMITH_ID",
"displayName" => "Jane Smith",
"userPrincipalName" => "jsmith@example.local",
"accountEnabled" => true
}
]
actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Auth.create_identity(
account: account,
provider: provider,
actor: actor,
provider_identifier: "USER_JDOE_ID"
)
other_actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Auth.create_identity(
account: account,
provider: provider,
actor: other_actor,
provider_identifier: "USER_JSMITH_ID"
)
deleted_identity =
Fixtures.Auth.create_identity(
account: account,
provider: provider,
actor: other_actor,
provider_identifier: "USER_JSMITH_ID2"
)
deleted_identity_token =
Fixtures.Tokens.create_token(
account: account,
actor: other_actor,
identity: deleted_identity
)
deleted_identity_client =
Fixtures.Clients.create_client(
account: account,
actor: other_actor,
identity: deleted_identity
)
deleted_identity_flow =
Fixtures.Flows.create_flow(
account: account,
client: deleted_identity_client,
token_id: deleted_identity_token.id
)
group =
Fixtures.Actors.create_group(
account: account,
provider: provider,
provider_identifier: "G:GROUP_ALL_ID"
)
deleted_group =
Fixtures.Actors.create_group(
account: account,
provider: provider,
provider_identifier: "G:DELETED_GROUP_ID!"
)
policy = Fixtures.Policies.create_policy(account: account, actor_group: group)
deleted_policy =
Fixtures.Policies.create_policy(account: account, actor_group: deleted_group)
deleted_group_flow =
Fixtures.Flows.create_flow(
account: account,
actor_group: deleted_group,
resource_id: deleted_policy.resource_id,
policy: deleted_policy
)
Fixtures.Actors.create_membership(account: account, actor: actor)
Fixtures.Actors.create_membership(account: account, actor: actor, group: group)
deleted_membership = Fixtures.Actors.create_membership(account: account, group: group)
Fixtures.Actors.create_membership(account: account, actor: actor, group: deleted_group)
:ok = Domain.Actors.subscribe_to_membership_updates_for_actor(actor)
:ok = Domain.Actors.subscribe_to_membership_updates_for_actor(other_actor)
:ok = Domain.Actors.subscribe_to_membership_updates_for_actor(deleted_membership.actor_id)
:ok = Domain.Policies.subscribe_to_events_for_actor(actor)
:ok = Domain.Policies.subscribe_to_events_for_actor(other_actor)
:ok = Domain.Policies.subscribe_to_events_for_actor_group(deleted_group)
:ok = Domain.Flows.subscribe_to_flow_expiration_events(deleted_group_flow)
:ok = Domain.Flows.subscribe_to_flow_expiration_events(deleted_identity_flow)
:ok = Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{deleted_identity_token.id}")
MicrosoftEntraDirectory.mock_groups_list_endpoint(bypass, groups)
MicrosoftEntraDirectory.mock_users_list_endpoint(bypass, users)
MicrosoftEntraDirectory.mock_group_members_list_endpoint(
bypass,
"GROUP_ALL_ID",
two_members
)
MicrosoftEntraDirectory.mock_group_members_list_endpoint(
bypass,
"GROUP_ENGINEERING_ID",
one_member
)
assert sync_directory(%{}) == :ok
assert updated_group = Repo.get(Domain.Actors.Group, group.id)
assert updated_group.name == "Group:All"
assert created_group =
Repo.get_by(Domain.Actors.Group, provider_identifier: "G:GROUP_ENGINEERING_ID")
assert created_group.name == "Group:Engineering"
assert memberships = Repo.all(Domain.Actors.Membership.Query.all())
assert length(memberships) == 4
assert memberships = Repo.all(Domain.Actors.Membership.Query.with_joined_groups())
assert length(memberships) == 4
membership_group_ids = Enum.map(memberships, & &1.group_id)
assert group.id in membership_group_ids
assert deleted_group.id not in membership_group_ids
# Deletes membership for a deleted group
actor_id = actor.id
group_id = deleted_group.id
assert_receive {:delete_membership, ^actor_id, ^group_id}
# Created membership for a new group
actor_id = actor.id
group_id = created_group.id
assert_receive {:create_membership, ^actor_id, ^group_id}
# Created membership for a member of existing group
other_actor_id = other_actor.id
group_id = group.id
assert_receive {:create_membership, ^other_actor_id, ^group_id}
# Broadcasts allow_access for it
policy_id = policy.id
group_id = group.id
resource_id = policy.resource_id
assert_receive {:allow_access, ^policy_id, ^group_id, ^resource_id}
# Deletes membership that is not found on IdP end
actor_id = deleted_membership.actor_id
group_id = deleted_membership.group_id
assert_receive {:delete_membership, ^actor_id, ^group_id}
# Signs out users which identity has been deleted
topic = "sessions:#{deleted_identity_token.id}"
assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "disconnect", payload: nil}
# Deleted group deletes all policies and broadcasts reject access events for them
policy_id = deleted_policy.id
group_id = deleted_group.id
resource_id = deleted_policy.resource_id
assert_receive {:reject_access, ^policy_id, ^group_id, ^resource_id}
# Deleted policies expire all flows authorized by them
flow_id = deleted_group_flow.id
assert_receive {:expire_flow, ^flow_id, _client_id, ^resource_id}
# Expires flows for signed out user
flow_id = deleted_identity_flow.id
assert_receive {:expire_flow, ^flow_id, _client_id, _resource_id}
# Should not do anything else
refute_receive {:create_membership, _actor_id, _group_id}
refute_received {:remove_membership, _actor_id, _group_id}
refute_received {:allow_access, _policy_id, _group_id, _resource_id}
refute_received {:reject_access, _policy_id, _group_id, _resource_id}
refute_received {:expire_flow, _flow_id, _client_id, _resource_id}
end
test "persists the sync error on the provider", %{provider: provider} do
bypass = Bypass.open()
MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
error_message = "Lifetime validation failed, the token is expired."
response = %{
"error" => %{
"code" => "InvalidAuthenticationToken",
"message" => "Lifetime validation failed, the token is expired.",
"innerError" => %{
"date" => "2024-02-02T19:22:43",
"request-id" => "db39f8ea-8072-4eb5-95e2-26b8f78c1673",
"client-request-id" => "db39f8ea-8072-4eb5-95e2-26b8f78c1673"
}
}
}
Bypass.expect_once(bypass, "GET", "v1.0/users", fn conn ->
Plug.Conn.send_resp(conn, 401, Jason.encode!(response))
end)
assert sync_directory(%{}) == :ok
assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id)
refute updated_provider.last_synced_at
assert updated_provider.last_syncs_failed == 1
assert updated_provider.last_sync_error == error_message
Bypass.expect_once(bypass, "GET", "v1.0/users", fn conn ->
Plug.Conn.send_resp(conn, 500, "")
end)
assert sync_directory(%{}) == :ok
assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id)
refute updated_provider.last_synced_at
assert updated_provider.last_syncs_failed == 2
assert updated_provider.last_sync_error == "Microsoft Graph API is temporarily unavailable"
end
end
end

View File

@@ -12,7 +12,8 @@ defmodule Domain.AuthTest do
assert adapters == %{
openid_connect: Domain.Auth.Adapters.OpenIDConnect,
google_workspace: Domain.Auth.Adapters.GoogleWorkspace
google_workspace: Domain.Auth.Adapters.GoogleWorkspace,
microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra
}
end
end

View File

@@ -19,6 +19,10 @@ defmodule Domain.Fixtures.Auth do
Ecto.UUID.generate()
end
def random_provider_identifier(%Domain.Auth.Provider{adapter: :microsoft_entra}) do
Ecto.UUID.generate()
end
def random_provider_identifier(%Domain.Auth.Provider{adapter: :userpass, name: name}) do
"user-#{unique_integer()}@#{String.downcase(name)}.com"
end
@@ -93,6 +97,24 @@ defmodule Domain.Fixtures.Auth do
{provider, bypass}
end
def start_and_create_microsoft_entra_provider(attrs \\ %{}) do
bypass = Domain.Mocks.OpenIDConnect.discovery_document_server()
adapter_config =
openid_connect_adapter_config(
discovery_document_uri:
"http://localhost:#{bypass.port}/.well-known/openid-configuration",
scope: Domain.Auth.Adapters.MicrosoftEntra.Settings.scope() |> Enum.join(" ")
)
provider =
attrs
|> Enum.into(%{adapter_config: adapter_config})
|> create_microsoft_entra_provider()
{provider, bypass}
end
def create_openid_connect_provider(attrs \\ %{}) do
attrs =
%{
@@ -147,6 +169,33 @@ defmodule Domain.Fixtures.Auth do
)
end
def create_microsoft_entra_provider(attrs \\ %{}) do
attrs =
%{
adapter: :microsoft_entra,
provisioner: :custom
}
|> Map.merge(Enum.into(attrs, %{}))
|> provider_attrs()
{account, attrs} =
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
Fixtures.Accounts.create_account(assoc_attrs)
end)
{:ok, provider} = Auth.create_provider(account, attrs)
update!(provider,
disabled_at: nil,
adapter_state: %{
"access_token" => "OIDC_ACCESS_TOKEN",
"refresh_token" => "OIDC_REFRESH_TOKEN",
"expires_at" => DateTime.utc_now() |> DateTime.add(1, :day),
"claims" => "openid email profile offline_access"
}
)
end
def create_userpass_provider(attrs \\ %{}) do
attrs =
attrs

View File

@@ -0,0 +1,143 @@
defmodule Domain.Mocks.MicrosoftEntraDirectory do
alias Domain.Auth.Adapters.MicrosoftEntra
def override_endpoint_url(url) do
config = Domain.Config.fetch_env!(:domain, MicrosoftEntra.APIClient)
config = Keyword.put(config, :endpoint, url)
Domain.Config.put_env_override(:domain, MicrosoftEntra.APIClient, config)
end
def mock_users_list_endpoint(bypass, users \\ nil) do
users_list_endpoint_path = "v1.0/users"
resp = %{
"@odata.context" =>
"https://graph.microsoft.com/v1.0/$metadata#users(id,displayName,userPrincipalName,mail,accountEnabled)",
"value" =>
users ||
[
%{
"id" => "8FBDDD1B-0E73-4CD0-AD38-2ACEA67814EE",
"displayName" => "John Doe",
"givenName" => "John",
"surname" => "Doe",
"userPrincipalName" => "jdoe@example.local",
"mail" => "jdoe@example.local",
"accountEnabled" => true
},
%{
"id" => "0B69CEE0-B884-4CAD-B7E3-DDD4D53034FB",
"displayName" => "Jane Smith",
"givenName" => "Jane",
"surname" => "Smith",
"userPrincipalName" => "jsmith@example.local",
"mail" => "jsmith@example.local",
"accountEnabled" => true
},
%{
"id" => "84F44A7C-DC31-4B2B-83F6-6CFCF0AA2456",
"displayName" => "Bob Smith",
"givenName" => "Bob",
"surname" => "Smith",
"userPrincipalName" => "bsmith@example.local",
"mail" => "bsmith@example.local",
"accountEnabled" => true
}
]
}
test_pid = self()
Bypass.expect(bypass, "GET", users_list_endpoint_path, fn conn ->
conn = Plug.Conn.fetch_query_params(conn)
send(test_pid, {:bypass_request, conn})
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
end)
override_endpoint_url("http://localhost:#{bypass.port}/")
bypass
end
def mock_groups_list_endpoint(bypass, groups \\ nil) do
groups_list_endpoint_path = "v1.0/groups"
resp = %{
"@odata.context" => "https://graph.microsoft.com/v1.0/$metadata#groups(id,displayName)",
"value" =>
groups ||
[
%{
"id" => "962F077E-CAA2-4873-9D7D-A37CD58C06F5",
"displayName" => "Engineering"
},
%{
"id" => "AFB58E30-EB1E-4A46-913B-20C6CE476CE6",
"displayName" => "Finance"
},
%{
"id" => "01E60A9C-4EE7-4253-87D9-8677E87A0A41",
"displayName" => "All"
}
]
}
test_pid = self()
Bypass.expect(bypass, "GET", groups_list_endpoint_path, fn conn ->
conn = Plug.Conn.fetch_query_params(conn)
send(test_pid, {:bypass_request, conn})
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
end)
override_endpoint_url("http://localhost:#{bypass.port}/")
bypass
end
def mock_group_members_list_endpoint(bypass, group_id, members \\ nil) do
group_members_list_endpoint_path =
"v1.0/groups/#{group_id}/transitiveMembers/microsoft.graph.user"
memberships =
members ||
[
%{
"id" => "8FBDDD1B-0E73-4CD0-AD38-2ACEA67814EE",
"displayName" => "John Doe",
"userPrincipalName" => "jdoe@example.local",
"accountEnabled" => true
},
%{
"id" => "0B69CEE0-B884-4CAD-B7E3-DDD4D53034FB",
"displayName" => "Jane Smith",
"userPrincipalName" => "jsmith@example.local",
"accountEnabled" => true
},
%{
"id" => "84F44A7C-DC31-4B2B-83F6-6CFCF0AA2456",
"displayName" => "Bob Smith",
"userPrincipalName" => "bsmith@example.local",
"accountEnabled" => true
}
]
resp = %{
"@odata.context" =>
"https://graph.microsoft.com/v1.0/$metadata#users(id,displayName,userPrincipalName,accountEnabled)",
"value" => memberships
}
test_pid = self()
Bypass.expect(bypass, "GET", group_members_list_endpoint_path, fn conn ->
conn = Plug.Conn.fetch_query_params(conn)
send(test_pid, {:bypass_request, conn})
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
end)
override_endpoint_url("http://localhost:#{bypass.port}/")
bypass
end
end

View File

@@ -123,6 +123,7 @@ defmodule Web.Settings.IdentityProviders.Components do
def adapter_name(:email), do: "Email"
def adapter_name(:userpass), do: "Username & Password"
def adapter_name(:google_workspace), do: "Google Workspace"
def adapter_name(:microsoft_entra), do: "Microsoft Entra"
def adapter_name(:openid_connect), do: "OpenID Connect"
def view_provider(account, %{adapter: adapter} = provider)
@@ -135,6 +136,9 @@ defmodule Web.Settings.IdentityProviders.Components do
def view_provider(account, %{adapter: :google_workspace} = provider),
do: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}"
def view_provider(account, %{adapter: :microsoft_entra} = provider),
do: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}"
def sync_status(%{provider: %{provisioner: :custom}} = assigns) do
~H"""
<div :if={not is_nil(@provider.last_synced_at)} class="flex items-center">

View File

@@ -0,0 +1,123 @@
defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Components do
use Web, :component_library
def provider_form(assigns) do
~H"""
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-12">
<.form for={@form} phx-change={:change} phx-submit={:submit}>
<.step>
<:title>Step 1. Create a new App Registration in Microsoft Entra</:title>
<:content>
<p class="mb-4">
Ensure the following scopes are added to the OAuth application:
</p>
<.code_block
id="oauth-scopes"
class="w-full text-xs mb-4 whitespace-pre-line rounded"
phx-no-format
><%= scopes() %></.code_block>
<p class="mb-4">
Ensure the OAuth application has the following redirect URLs whitelisted:
</p>
<p class="mt-4">
<.code_block
:for={
{type, redirect_url} <- [
sign_in: url(~p"/#{@account.id}/sign_in/providers/#{@id}/handle_callback"),
connect:
url(
~p"/#{@account.id}/settings/identity_providers/microsoft_entra/#{@id}/handle_callback"
)
]
}
id={"redirect_url-#{type}"}
class="w-full mb-4 text-xs whitespace-nowrap rounded"
phx-no-format
><%= redirect_url %></.code_block>
</p>
</:content>
</.step>
<.step>
<:title>Step 2. Configure Firezone</:title>
<:content>
<.base_error form={@form} field={:base} />
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.input
label="Name"
autocomplete="off"
field={@form[:name]}
placeholder="Name this identity provider"
required
/>
<p class="mt-2 text-xs text-neutral-500">
A friendly name for this identity provider. This will be displayed to end-users.
</p>
</div>
<.inputs_for :let={adapter_config_form} field={@form[:adapter_config]}>
<div>
<.input
label="Client ID"
autocomplete="off"
field={adapter_config_form[:client_id]}
required
/>
<p class="mt-2 text-xs text-neutral-500">
The Client ID from the previous step.
</p>
</div>
<div>
<.input
label="Client secret"
autocomplete="off"
field={adapter_config_form[:client_secret]}
required
/>
<p class="mt-2 text-xs text-neutral-500">
The Client secret from the previous step.
</p>
</div>
<div>
<.input
label="Discovery Document URI"
autocomplete="off"
field={adapter_config_form[:discovery_document_uri]}
placeholder="https://login.microsoftonline.com/<azure-tenant-id>/v2.0/.well-known/openid-configuration"
required
/>
<p class="mt-2 text-xs text-neutral-500">
The URI to the OpenID Connect discovery document for your Microsoft Entra ID provider.
</p>
</div>
</.inputs_for>
</div>
<.submit_button>
Connect Identity Provider
</.submit_button>
</:content>
</.step>
</.form>
</div>
"""
end
def scopes do
"""
openid
profile
email
offline_access
Group.Read.All
GroupMember.Read.All
User.Read
User.Read.All
"""
end
end

View File

@@ -0,0 +1,115 @@
defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Connect do
@doc """
This controller is similar to Web.AuthController, but it is used to connect IdP account
to the actor and provider rather than logging in using it.
"""
use Web, :controller
alias Domain.Auth.Adapters.MicrosoftEntra
def redirect_to_idp(conn, %{"provider_id" => provider_id}) do
account = conn.assigns.account
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject) do
redirect_url =
url(
~p"/#{provider.account_id}/settings/identity_providers/microsoft_entra/#{provider}/handle_callback"
)
Web.AuthController.redirect_to_idp(conn, redirect_url, provider, %{prompt: "consent"})
else
{:error, :not_found} ->
conn
|> put_flash(:error, "Provider does not exist.")
|> redirect(to: ~p"/#{account}/settings/identity_providers")
end
end
def handle_idp_callback(conn, %{
"provider_id" => provider_id,
"state" => state,
"code" => code
}) do
account = conn.assigns.account
subject = conn.assigns.subject
with {:ok, _redirect_params, code_verifier, conn} <-
Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do
payload = {
url(
~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider_id}/handle_callback"
),
code_verifier,
code
}
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject),
{:ok, identity} <-
MicrosoftEntra.verify_and_upsert_identity(subject.actor, provider, payload),
attrs = %{
adapter_state: identity.provider_state,
disabled_at: nil,
last_syncs_failed: 0,
last_sync_error: nil
},
{:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do
redirect(conn,
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
else
{:error, :expired_token} ->
conn
|> put_flash(:error, "The provider returned an expired token, please try again.")
|> redirect(
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
{:error, :invalid_token} ->
conn
|> put_flash(:error, "The provider returned an invalid token, please try again.")
|> redirect(
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
{:error, :not_found} ->
conn
|> put_flash(:error, "Provider does not exist.")
|> redirect(
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
{:error, %Ecto.Changeset{} = changeset} ->
errors =
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
|> Map.get(:adapter_config, %{})
conn
|> put_flash(
:error,
{:validation_errors, "There is an error with provider behaviour", errors}
)
|> redirect(
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
{:error, _reason} ->
conn
|> put_flash(:error, "You may not authenticate to this account.")
|> redirect(
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
end
else
{:error, :invalid_state, conn} ->
conn
|> put_flash(:error, "Your session has expired, please try again.")
|> redirect(
to: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider_id}"
)
end
end
end

View File

@@ -0,0 +1,69 @@
defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Edit do
use Web, :live_view
import Web.Settings.IdentityProviders.MicrosoftEntra.Components
alias Domain.Auth
def mount(%{"provider_id" => provider_id}, _session, socket) do
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, socket.assigns.subject) do
changeset = Auth.change_provider(provider)
socket =
assign(socket,
provider: provider,
form: to_form(changeset),
page_title: "Edit #{provider.name}"
)
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers"}>
Identity Providers Settings
</.breadcrumb>
<.breadcrumb path={
~p"/#{@account}/settings/identity_providers/microsoft_entra/#{@form.data}/edit"
}>
Edit <%= # {@form.data.name} %>
</.breadcrumb>
</.breadcrumbs>
<.section>
<:title>
Edit Identity Provider <%= @form.data.name %>
</:title>
<:content>
<.provider_form account={@account} id={@form.data.id} form={@form} />
</:content>
</.section>
"""
end
def handle_event("change", %{"provider" => attrs}, socket) do
changeset =
Auth.change_provider(socket.assigns.provider, attrs)
|> Map.put(:action, :insert)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("submit", %{"provider" => attrs}, socket) do
with {:ok, provider} <-
Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) do
socket =
push_navigate(socket,
to:
~p"/#{socket.assigns.account.id}/settings/identity_providers/microsoft_entra/#{provider}/redirect"
)
{:noreply, socket}
else
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
end

View File

@@ -0,0 +1,94 @@
defmodule Web.Settings.IdentityProviders.MicrosoftEntra.New do
use Web, :live_view
import Web.Settings.IdentityProviders.MicrosoftEntra.Components
alias Domain.Auth
def mount(_params, _session, socket) do
id = Ecto.UUID.generate()
changeset =
Auth.new_provider(socket.assigns.account, %{
name: "Microsoft Entra",
adapter: :microsoft_entra,
adapter_config: %{}
})
socket =
assign(socket,
id: id,
form: to_form(changeset),
page_title: "New Identity Provider"
)
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers"}>
Identity Providers Settings
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}>
Create Identity Provider
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/microsoft_entra/new"}>
Microsoft Entra
</.breadcrumb>
</.breadcrumbs>
<.section>
<:title>
Add a new Microsoft Entra Identity Provider
</:title>
<:help>
For a more detailed guide on setting up Firezone with Microsoft Entra, please <.link class={
link_style()
}>refer to our documentation</.link>.
</:help>
<:content>
<.provider_form account={@account} id={@id} form={@form} />
</:content>
</.section>
"""
end
def handle_event("change", %{"provider" => attrs}, socket) do
attrs = Map.put(attrs, "adapter", :microsoft_entra)
changeset =
Auth.new_provider(socket.assigns.account, attrs)
|> Map.put(:action, :insert)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("submit", %{"provider" => attrs}, socket) do
attrs =
attrs
|> Map.put("id", socket.assigns.id)
|> Map.put("adapter", :microsoft_entra)
# We create provider in a disabled state because we need to write access token for it first
|> Map.put("adapter_state", %{status: "pending_access_token"})
|> Map.put("disabled_at", DateTime.utc_now())
with {:ok, provider} <-
Auth.create_provider(socket.assigns.account, attrs, socket.assigns.subject) do
socket =
push_navigate(socket,
to:
~p"/#{socket.assigns.account.id}/settings/identity_providers/microsoft_entra/#{provider}/redirect"
)
{:noreply, socket}
else
{:error, changeset} ->
# Here we can have an insert conflict error, which will be returned without embedded fields information,
# this will crash `.inputs_for` component in the template, so we need to handle it here.
new_changeset =
Auth.new_provider(socket.assigns.account, attrs)
|> Map.put(:action, :insert)
{:noreply, assign(socket, form: to_form(%{new_changeset | errors: changeset.errors}))}
end
end
end

View File

@@ -0,0 +1,191 @@
defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Show do
use Web, :live_view
import Web.Settings.IdentityProviders.Components
alias Domain.{Auth, Actors}
def mount(%{"provider_id" => provider_id}, _session, socket) do
with {:ok, provider} <-
Auth.fetch_provider_by_id(provider_id, socket.assigns.subject,
preload: [created_by_identity: [:actor]]
),
{:ok, identities_count_by_provider_id} <-
Auth.fetch_identities_count_grouped_by_provider_id(socket.assigns.subject),
{:ok, groups_count_by_provider_id} <-
Actors.fetch_groups_count_grouped_by_provider_id(socket.assigns.subject) do
{:ok,
assign(socket,
provider: provider,
identities_count_by_provider_id: identities_count_by_provider_id,
groups_count_by_provider_id: groups_count_by_provider_id,
page_title: "Identity Provider #{provider.name}"
)}
else
_ -> raise Web.LiveErrors.NotFoundError
end
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers"}>
Identity Providers Settings
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/microsoft_entra/#{@provider}"}>
<%= @provider.name %>
</.breadcrumb>
</.breadcrumbs>
<.section>
<:title>
Identity Provider <code><%= @provider.name %></code>
<span :if={not is_nil(@provider.disabled_at)} class="text-primary-600">(disabled)</span>
<span :if={not is_nil(@provider.deleted_at)} class="text-red-600">(deleted)</span>
</:title>
<:action :if={is_nil(@provider.deleted_at)}>
<.edit_button navigate={
~p"/#{@account}/settings/identity_providers/microsoft_entra/#{@provider.id}/edit"
}>
Edit
</.edit_button>
</:action>
<:action :if={is_nil(@provider.deleted_at)}>
<.button
:if={is_nil(@provider.disabled_at)}
phx-click="disable"
style="warning"
icon="hero-lock-closed"
data-confirm="Are you sure want to disable this provider? Users will no longer be able to sign in with this provider and user / group sync will be paused."
>
Disable
</.button>
<%= if @provider.adapter_state["status"] != "pending_access_token" do %>
<.button
:if={not is_nil(@provider.disabled_at)}
phx-click="enable"
style="warning"
icon="hero-lock-open"
data-confirm="Are you sure want to enable this provider?"
>
Enable
</.button>
<% end %>
</:action>
<:action :if={is_nil(@provider.deleted_at)}>
<.button
style="primary"
navigate={
~p"/#{@account.id}/settings/identity_providers/microsoft_entra/#{@provider}/redirect"
}
icon="hero-arrow-path"
>
Reconnect
</.button>
</:action>
<:content>
<.header>
<:title>Details</:title>
</.header>
<.flash_group flash={@flash} />
<div class="bg-white overflow-hidden">
<.vertical_table id="provider">
<.vertical_table_row>
<:label>Name</:label>
<:value><%= @provider.name %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Status</:label>
<:value>
<.status provider={@provider} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Sync Status</:label>
<:value>
<.sync_status
account={@account}
provider={@provider}
identities_count_by_provider_id={@identities_count_by_provider_id}
groups_count_by_provider_id={@groups_count_by_provider_id}
/>
<div
:if={
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
(@provider.last_syncs_failed > 3 and not is_nil(@provider.last_sync_error))
}
class="p-3 mt-2 border-l-4 border-red-500 bg-red-100 rounded-md"
>
<p class="font-medium text-red-700">
IdP provider reported an error during the last sync:
</p>
<div class="flex items-center mt-1">
<span class="text-red-500 font-mono"><%= @provider.last_sync_error %></span>
</div>
</div>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Client ID</:label>
<:value><%= @provider.adapter_config["client_id"] %></:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Created</:label>
<:value>
<.created_by account={@account} schema={@provider} />
</:value>
</.vertical_table_row>
</.vertical_table>
</div>
</:content>
</.section>
<.danger_zone :if={is_nil(@provider.deleted_at)}>
<:action>
<.delete_button
data-confirm="Are you sure want to delete this provider along with all related data?"
phx-click="delete"
>
Delete Identity Provider
</.delete_button>
</:action>
<:content></:content>
</.danger_zone>
"""
end
def handle_event("delete", _params, socket) do
{:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject)
{:noreply,
push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")}
end
def handle_event("enable", _params, socket) do
attrs = %{disabled_at: nil}
{:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject)
{:ok, provider} =
Auth.fetch_provider_by_id(provider.id, socket.assigns.subject,
preload: [created_by_identity: [:actor]]
)
{:noreply, assign(socket, provider: provider)}
end
def handle_event("disable", _params, socket) do
attrs = %{disabled_at: DateTime.utc_now()}
{:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject)
{:ok, provider} =
Auth.fetch_provider_by_id(provider.id, socket.assigns.subject,
preload: [created_by_identity: [:actor]]
)
{:noreply, assign(socket, provider: provider)}
end
end

View File

@@ -58,6 +58,17 @@ defmodule Web.Settings.IdentityProviders.New do
"""
end
def adapter(%{adapter: :microsoft_entra} = assigns) do
~H"""
<.adapter_item
adapter={@adapter}
account={@account}
name="Microsoft Entra"
description="Authenticate users and synchronize users and groups with a custom Microsoft Entra connector."
/>
"""
end
def adapter(%{adapter: :openid_connect} = assigns) do
~H"""
<.adapter_item
@@ -95,7 +106,7 @@ defmodule Web.Settings.IdentityProviders.New do
<label for={"idp-option-#{@adapter}"} class="block ml-2 text-lg text-neutral-900">
<%= @name %>
</label>
<%= if @adapter == :google_workspace do %>
<%= if @adapter == :google_workspace || @adapter == :microsoft_entra do %>
<.badge class="ml-2" type="primary" title="Feature available on the Enterprise plan">
ENTERPRISE
</.badge>
@@ -115,4 +126,8 @@ defmodule Web.Settings.IdentityProviders.New do
def next_step_path(:google_workspace, account) do
~p"/#{account}/settings/identity_providers/google_workspace/new"
end
def next_step_path(:microsoft_entra, account) do
~p"/#{account}/settings/identity_providers/microsoft_entra/new"
end
end

View File

@@ -229,6 +229,16 @@ defmodule Web.Router do
get "/:provider_id/handle_callback", Connect, :handle_idp_callback
end
scope "/microsoft_entra", MicrosoftEntra do
live "/new", New
live "/:provider_id", Show
live "/:provider_id/edit", Edit
# OpenID Connection
get "/:provider_id/redirect", Connect, :redirect_to_idp
get "/:provider_id/handle_callback", Connect, :handle_idp_callback
end
scope "/system", System do
live "/:provider_id", Show
end

View File

@@ -47,6 +47,10 @@ config :domain, Domain.Auth.Adapters.GoogleWorkspace.APIClient,
endpoint: "https://admin.googleapis.com",
finch_transport_opts: []
config :domain, Domain.Auth.Adapters.MicrosoftEntra.APIClient,
endpoint: "https://graph.microsoft.com",
finch_transport_opts: []
config :domain, platform_adapter: nil
config :domain, Domain.GoogleCloudPlatform,