mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
%{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
##############################################
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user