diff --git a/elixir/apps/domain/lib/domain/auth/adapters.ex b/elixir/apps/domain/lib/domain/auth/adapters.ex index c672d6e45..f123daf16 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters.ex @@ -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 } diff --git a/elixir/apps/domain/lib/domain/auth/adapters/common/sync_logger.ex b/elixir/apps/domain/lib/domain/auth/adapters/common/sync_logger.ex new file mode 100644 index 000000000..9efa91a10 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/common/sync_logger.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex index 76fb7a1da..a035f6aa6 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex @@ -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", diff --git a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra.ex b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra.ex new file mode 100644 index 000000000..7a0028c8c --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/api_client.ex b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/api_client.ex new file mode 100644 index 000000000..549757e17 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/api_client.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex new file mode 100644 index 000000000..a04dd3b49 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/settings.ex b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/settings.ex new file mode 100644 index 000000000..9bfc77b4f --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/settings.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/settings/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/settings/changeset.ex new file mode 100644 index 000000000..e8d3779ce --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/settings/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex index af16d5b24..a8e348a10 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex @@ -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, %{ diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index f65dec3d3..cee09d921 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 5a99a1070..30f435485 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -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 ) ############################################## diff --git a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/api_client_test.exs b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/api_client_test.exs new file mode 100644 index 000000000..01140af14 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/api_client_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs new file mode 100644 index 000000000..795d63631 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 4917627f8..14fe0ed96 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 4cacbf01d..11e13461b 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -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 diff --git a/elixir/apps/domain/test/support/mocks/microsoft_entra_directory.ex b/elixir/apps/domain/test/support/mocks/microsoft_entra_directory.ex new file mode 100644 index 000000000..5798ca5eb --- /dev/null +++ b/elixir/apps/domain/test/support/mocks/microsoft_entra_directory.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex index ba43b4d0f..2f252d24e 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex @@ -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"""
+ Ensure the following scopes are added to the OAuth application: +
+ <.code_block + id="oauth-scopes" + class="w-full text-xs mb-4 whitespace-pre-line rounded" + phx-no-format + ><%= scopes() %> + ++ Ensure the OAuth application has the following redirect URLs whitelisted: +
++ <.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 %> +
+ + + + <.step> + <:title>Step 2. Configure Firezone + <:content> + <.base_error form={@form} field={:base} /> + ++ A friendly name for this identity provider. This will be displayed to end-users. +
++ The Client ID from the previous step. +
++ The Client secret from the previous step. +
++ The URI to the OpenID Connect discovery document for your Microsoft Entra ID provider. +
+<%= @provider.name %>
+ (disabled)
+ (deleted)
+
+ <:action :if={is_nil(@provider.deleted_at)}>
+ <.edit_button navigate={
+ ~p"/#{@account}/settings/identity_providers/microsoft_entra/#{@provider.id}/edit"
+ }>
+ Edit
+
+
+ <: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
+
+
+ <%= 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
+
+ <% end %>
+
+ <: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
+
+
+ <:content>
+ <.header>
+ <:title>Details
+
+
+ <.flash_group flash={@flash} />
+
+ + IdP provider reported an error during the last sync: +
+