diff --git a/docker-compose.yml b/docker-compose.yml index 7be000a36..221011c3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: DATABASE_USER: postgres DATABASE_PASSWORD: postgres # Auth - AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace,microsoft_entra" + AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace,microsoft_entra,okta" # Secrets TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" diff --git a/elixir/apps/domain/lib/domain/auth/adapters.ex b/elixir/apps/domain/lib/domain/auth/adapters.ex index f123daf16..898ff021c 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters.ex @@ -7,6 +7,7 @@ defmodule Domain.Auth.Adapters do openid_connect: Domain.Auth.Adapters.OpenIDConnect, google_workspace: Domain.Auth.Adapters.GoogleWorkspace, microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra, + okta: Domain.Auth.Adapters.Okta, userpass: Domain.Auth.Adapters.UserPass } diff --git a/elixir/apps/domain/lib/domain/auth/adapters/okta.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta.ex new file mode 100644 index 000000000..097581f78 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta.ex @@ -0,0 +1,82 @@ +defmodule Domain.Auth.Adapters.Okta do + use Supervisor + alias Domain.Actors + alias Domain.Auth.{Provider, Adapter} + alias Domain.Auth.Adapters.OpenIDConnect + alias Domain.Auth.Adapters.Okta + 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 = [ + Okta.APIClient, + {Domain.Jobs, Okta.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(Okta.Settings, current_attrs, :json) + |> Okta.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) + end + + def verify_and_upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, payload) do + OpenIDConnect.verify_and_upsert_identity(actor, provider, payload) + 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/okta/api_client.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex new file mode 100644 index 000000000..a23300724 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex @@ -0,0 +1,149 @@ +defmodule Domain.Auth.Adapters.Okta.APIClient do + use Supervisor + + @pool_name __MODULE__.Finch + + 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(endpoint, api_token) do + uri = + URI.parse("#{endpoint}/api/v1/users") + |> URI.append_query( + URI.encode_query(%{ + "limit" => 200 + }) + ) + + headers = [ + {"Content-Type", "application/json; okta-response=omitCredentials,omitCredentialsLinks"} + ] + + with {:ok, users} <- list_all(uri, headers, api_token) do + active_users = + Enum.filter(users, fn user -> + user["status"] == "ACTIVE" + end) + + {:ok, active_users} + end + end + + def list_groups(endpoint, api_token) do + uri = + URI.parse("#{endpoint}/api/v1/groups") + |> URI.append_query( + URI.encode_query(%{ + "limit" => 200 + }) + ) + + headers = [] + + list_all(uri, headers, api_token) + end + + def list_group_members(endpoint, api_token, group_id) do + uri = + URI.parse("#{endpoint}/api/v1/groups/#{group_id}/users") + |> URI.append_query( + URI.encode_query(%{ + "limit" => 200 + }) + ) + + headers = [] + + with {:ok, members} <- list_all(uri, headers, api_token) do + enabled_members = + Enum.filter(members, fn member -> + member["status"] == "ACTIVE" + end) + + {:ok, enabled_members} + end + end + + defp list_all(uri, headers, api_token, acc \\ []) do + case list(uri, headers, 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(headers, api_token, [list | acc]) + + {:error, reason} -> + {:error, reason} + end + end + + defp list(uri, headers, api_token) do + headers = headers ++ [{"Authorization", "Bearer #{api_token}"}] + request = Finch.build(:get, uri, headers) + + with {:ok, %Finch.Response{headers: headers, body: response, status: status}} + when status in 200..299 <- Finch.request(request, @pool_name), + {:ok, list} <- Jason.decode(response) do + {:ok, list, fetch_next_link(headers)} + 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 + + defp fetch_next_link(headers) do + headers + |> Enum.find(fn {name, value} -> + name == "link" && String.contains?(value, "rel=\"next\"") + end) + |> parse_link_header() + end + + defp parse_link_header({_name, value}) do + [raw_url | _] = String.split(value, ";") + + raw_url + |> String.replace_prefix("<", "") + |> String.replace_suffix(">", "") + end + + defp parse_link_header(nil), do: nil +end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex new file mode 100644 index 000000000..c73d05aca --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex @@ -0,0 +1,173 @@ +defmodule Domain.Auth.Adapters.Okta.Jobs do + use Domain.Jobs.Recurrent, otp_app: :domain + alias Domain.{Auth, Actors} + alias Domain.Auth.Adapters.Okta + 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(:okta) do + Logger.debug("Refreshing access tokens for #{length(providers)} Okta providers") + + Enum.each(providers, fn provider -> + Logger.debug("Refreshing access token", + provider_id: provider.id, + account_id: provider.account_id + ) + + case Okta.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(:okta) do + Logger.debug("Syncing #{length(providers)} Okta 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) + + endpoint = provider.adapter_config["api_base_url"] + access_token = provider.adapter_state["access_token"] + + with {:ok, users} <- Okta.APIClient.list_users(endpoint, access_token), + {:ok, groups} <- Okta.APIClient.list_groups(endpoint, access_token), + {:ok, tuples} <- list_membership_tuples(endpoint, 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, %{"errorCode" => error_code, "errorSummary" => error_summary}}} -> + message = "#{error_code} => #{error_summary}" + + provider = + Auth.Provider.Changeset.sync_failed(provider, message) + |> Domain.Repo.update!() + + log_sync_error(provider, "Okta API returned #{status}: #{message}") + + {:error, :retry_later} -> + message = "Okta 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(endpoint, access_token, groups) do + Enum.reduce_while(groups, {:ok, []}, fn group, {:ok, tuples} -> + case Okta.APIClient.list_group_members(endpoint, 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 Okta to Domain + defp map_identity_attrs(users) do + Enum.map(users, fn user -> + %{ + "provider_identifier" => user["id"], + "provider_state" => %{ + "userinfo" => %{ + "email" => user["profile"]["email"] + } + }, + "actor" => %{ + "type" => :account_user, + "name" => "#{user["profile"]["firstName"]} #{user["profile"]["lastName"]}" + } + } + end) + end + + # Map group attributes from Okta to Domain + defp map_group_attrs(groups) do + Enum.map(groups, fn group -> + %{ + "name" => "Group:" <> group["profile"]["name"], + "provider_identifier" => "G:" <> group["id"] + } + end) + end +end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/okta/settings.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta/settings.ex new file mode 100644 index 000000000..4b9268e78 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta/settings.ex @@ -0,0 +1,23 @@ +defmodule Domain.Auth.Adapters.Okta.Settings do + use Domain, :schema + + @scope ~w[ + openid email profile + offline_access + okta.groups.read + okta.users.read + ] + + @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 + field :oauth_uri, :string + field :api_base_url, :string + end + + def scope, do: @scope +end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/okta/settings/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta/settings/changeset.ex new file mode 100644 index 000000000..664421bca --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta/settings/changeset.ex @@ -0,0 +1,25 @@ +defmodule Domain.Auth.Adapters.Okta.Settings.Changeset do + use Domain, :changeset + alias Domain.Auth.Adapters.Okta.Settings + alias Domain.Auth.Adapters.OpenIDConnect + + @fields ~w[scope + response_type + client_id client_secret + discovery_document_uri + oauth_uri + api_base_url]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/settings/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex index 63b96d52b..01c3943a4 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex @@ -17,7 +17,8 @@ defmodule Domain.Auth.Adapters.OpenIDConnect.Settings.Changeset do def validate_discovery_document_uri(changeset) do validate_change(changeset, :discovery_document_uri, fn :discovery_document_uri, value -> - with {:ok, %URI{scheme: scheme, host: host}} when not is_nil(scheme) and not is_nil(host) <- + with {:ok, %URI{scheme: scheme, host: host}} + when not is_nil(scheme) and not is_nil(host) and host != "" <- URI.new(value), {:ok, _update_result} <- OpenIDConnect.Document.fetch_document(value) do [] @@ -37,6 +38,9 @@ defmodule Domain.Auth.Adapters.OpenIDConnect.Settings.Changeset do {:error, {status, _body}} -> [{:discovery_document_uri, "is invalid, got #{status} HTTP response"}] + + {:error, _} -> + [{:discovery_document_uri, "invalid URL"}] end end) end diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index cee09d921..4703e558d 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -5,7 +5,7 @@ defmodule Domain.Auth.Provider do field :name, :string field :adapter, Ecto.Enum, - values: ~w[email openid_connect google_workspace microsoft_entra userpass]a + values: ~w[email openid_connect google_workspace microsoft_entra okta userpass]a field :provisioner, Ecto.Enum, values: ~w[manual just_in_time custom]a field :adapter_config, :map diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 30f435485..ba97eea3f 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -449,10 +449,11 @@ defmodule Domain.Config.Definitions do openid_connect google_workspace microsoft_entra + okta userpass token ]a)}}, - default: ~w[email openid_connect google_workspace microsoft_entra token]a + default: ~w[email openid_connect google_workspace microsoft_entra okta token]a ) ############################################## diff --git a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra_test.exs b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra_test.exs new file mode 100644 index 000000000..220faf2a5 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra_test.exs @@ -0,0 +1,319 @@ +defmodule Domain.Auth.Adapters.MicrosoftEntraTest do + use Domain.DataCase, async: true + import Domain.Auth.Adapters.MicrosoftEntra + alias Domain.Auth + alias Domain.Auth.Adapters.OpenIDConnect.PKCE + + describe "identity_changeset/2" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + changeset = %Auth.Identity{} |> Ecto.Changeset.change() + + %{ + bypass: bypass, + account: account, + provider: provider, + changeset: changeset + } + end + + test "puts default provider state", %{provider: provider, changeset: changeset} do + changeset = + Ecto.Changeset.put_change(changeset, :provider_virtual_state, %{ + "userinfo" => %{"email" => "foo@example.com"} + }) + + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + + assert changeset.changes == %{ + provider_virtual_state: %{}, + provider_state: %{"userinfo" => %{"email" => "foo@example.com"}} + } + end + + test "trims provider identifier", %{provider: provider, changeset: changeset} do + changeset = Ecto.Changeset.put_change(changeset, :provider_identifier, " X ") + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + assert changeset.changes.provider_identifier == "X" + end + end + + describe "provider_changeset/1" do + test "returns changeset errors in invalid adapter config" do + changeset = Ecto.Changeset.change(%Auth.Provider{}, %{}) + assert %Ecto.Changeset{} = changeset = provider_changeset(changeset) + assert errors_on(changeset) == %{adapter_config: ["can't be blank"]} + + attrs = Fixtures.Auth.provider_attrs(adapter: :microsoft_entra, adapter_config: %{}) + changeset = Ecto.Changeset.change(%Auth.Provider{}, attrs) + assert %Ecto.Changeset{} = changeset = provider_changeset(changeset) + + assert errors_on(changeset) == %{ + adapter_config: %{ + client_id: ["can't be blank"], + client_secret: ["can't be blank"], + discovery_document_uri: ["can't be blank"] + } + } + end + + test "returns changeset on valid adapter config" do + account = Fixtures.Accounts.create_account() + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + discovery_document_url = "http://localhost:#{bypass.port}/.well-known/openid-configuration" + + attrs = + Fixtures.Auth.provider_attrs( + adapter: :microsoft_entra, + adapter_config: %{ + client_id: "client_id", + client_secret: "client_secret", + discovery_document_uri: discovery_document_url + } + ) + + changeset = Ecto.Changeset.change(%Auth.Provider{account_id: account.id}, attrs) + + assert %Ecto.Changeset{} = changeset = provider_changeset(changeset) + assert {:ok, provider} = Repo.insert(changeset) + + assert provider.name == attrs.name + assert provider.adapter == attrs.adapter + + assert provider.adapter_config == %{ + "scope" => + Enum.join( + [ + "openid", + "email", + "profile", + "offline_access", + "Group.Read.All", + "GroupMember.Read.All", + "User.Read", + "User.Read.All" + ], + " " + ), + "response_type" => "code", + "client_id" => "client_id", + "client_secret" => "client_secret", + "discovery_document_uri" => discovery_document_url + } + end + end + + describe "ensure_deprovisioned/1" do + test "does nothing for a provider" do + {provider, _bypass} = Fixtures.Auth.start_and_create_microsoft_entra_provider() + assert ensure_deprovisioned(provider) == {:ok, provider} + end + end + + describe "verify_and_update_identity/2" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + + %{account: account, provider: provider, identity: identity, bypass: bypass} + end + + test "persists just the id token to adapter state", %{ + provider: provider, + identity: identity, + bypass: bypass + } do + email = "foo@example.com" + sub = Ecto.UUID.generate() + + {token, claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => identity.provider_identifier, + "email" => email, + "sub" => sub + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + Mocks.OpenIDConnect.expect_userinfo(bypass, %{ + "email" => email, + "sub" => sub + }) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert {:ok, identity, expires_at} = verify_and_update_identity(provider, payload) + + assert identity.provider_state == %{ + "access_token" => nil, + "claims" => claims, + "expires_at" => expires_at, + "refresh_token" => nil, + "userinfo" => %{ + "email" => email, + "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" => sub + } + } + end + + test "persists all token details to the adapter state", %{ + provider: provider, + identity: identity, + bypass: bypass + } do + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => identity.provider_identifier, + "email" => "foobar@example.com", + "sub" => Ecto.UUID.generate() + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{ + "token_type" => "Bearer", + "id_token" => token, + "access_token" => "MY_ACCESS_TOKEN", + "refresh_token" => "MY_REFRESH_TOKEN", + "expires_in" => 3600 + }) + + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert {:ok, identity, _expires_at} = verify_and_update_identity(provider, payload) + + assert identity.provider_state["access_token"] == "MY_ACCESS_TOKEN" + assert identity.provider_state["refresh_token"] == "MY_REFRESH_TOKEN" + + assert DateTime.diff(identity.provider_state["expires_at"], DateTime.utc_now()) in 3595..3605 + end + + test "returns error when token is expired", %{ + provider: provider, + identity: identity, + bypass: bypass + } do + forty_seconds_ago = DateTime.utc_now() |> DateTime.add(-40, :second) |> DateTime.to_unix() + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "exp" => forty_seconds_ago + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :expired} + end + + test "returns error when token is invalid", %{ + provider: provider, + bypass: bypass + } do + token = "foo" + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :invalid} + end + + test "returns error when identity does not exist", %{ + identity: identity, + provider: provider, + bypass: bypass + } do + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => Ecto.UUID.generate(), + "email" => "foobar@example.com", + "sub" => Ecto.UUID.generate() + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{ + "token_type" => "Bearer", + "id_token" => token, + "access_token" => "MY_ACCESS_TOKEN", + "refresh_token" => "MY_REFRESH_TOKEN", + "expires_in" => 3600 + }) + + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :not_found} + end + + test "returns error when identity does not belong to provider", %{ + account: account, + provider: provider, + bypass: bypass + } do + identity = Fixtures.Auth.create_identity(account: account) + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => identity.provider_identifier, + "email" => "foobar@example.com", + "sub" => Ecto.UUID.generate() + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{ + "token_type" => "Bearer", + "id_token" => token, + "access_token" => "MY_ACCESS_TOKEN", + "refresh_token" => "MY_REFRESH_TOKEN", + "expires_in" => 3600 + }) + + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :not_found} + end + + test "returns error when provider is down", %{ + provider: provider, + bypass: bypass + } do + Bypass.down(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :internal_error} + end + end +end diff --git a/elixir/apps/domain/test/domain/auth/adapters/okta/api_client_test.exs b/elixir/apps/domain/test/domain/auth/adapters/okta/api_client_test.exs new file mode 100644 index 000000000..482b0ac88 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/okta/api_client_test.exs @@ -0,0 +1,121 @@ +defmodule Domain.Auth.Adapters.Okta.APIClientTest do + use ExUnit.Case, async: true + alias Domain.Mocks.OktaDirectory + import Domain.Auth.Adapters.Okta.APIClient + + describe "list_users/1" do + test "returns list of users" do + api_token = Ecto.UUID.generate() + bypass = Bypass.open() + api_base_url = "http://localhost:#{bypass.port}/" + OktaDirectory.mock_users_list_endpoint(bypass) + + assert {:ok, users} = list_users(api_base_url, api_token) + assert length(users) == 2 + + for user <- users do + assert Map.has_key?(user, "id") + assert Map.has_key?(user, "profile") + assert Map.has_key?(user, "status") + + # Profile fields + assert Map.has_key?(user["profile"], "firstName") + assert Map.has_key?(user["profile"], "lastName") + assert Map.has_key?(user["profile"], "email") + assert Map.has_key?(user["profile"], "login") + end + + assert_receive {:bypass_request, conn} + + assert conn.params == %{"limit" => "200"} + + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"] + end + + test "returns error when Okta API is down" do + api_token = Ecto.UUID.generate() + bypass = Bypass.open() + api_base_url = "http://localhost:#{bypass.port}/" + Bypass.down(bypass) + + assert list_users(api_base_url, 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() + api_base_url = "http://localhost:#{bypass.port}/" + OktaDirectory.mock_groups_list_endpoint(bypass) + + assert {:ok, groups} = list_groups(api_base_url, api_token) + assert length(groups) == 4 + + for group <- groups do + assert Map.has_key?(group, "id") + assert Map.has_key?(group, "type") + assert Map.has_key?(group, "profile") + assert Map.has_key?(group, "_links") + + # Profile fields + assert Map.has_key?(group["profile"], "name") + assert Map.has_key?(group["profile"], "description") + end + + assert_receive {:bypass_request, conn} + + assert conn.params == %{"limit" => "200"} + + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"] + end + + test "returns error when Okta API is down" do + api_token = Ecto.UUID.generate() + bypass = Bypass.open() + api_base_url = "http://localhost:#{bypass.port}/" + Bypass.down(bypass) + + assert list_groups(api_base_url, 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() + api_base_url = "http://localhost:#{bypass.port}/" + OktaDirectory.mock_group_members_list_endpoint(bypass, group_id) + + assert {:ok, members} = list_group_members(api_base_url, api_token, group_id) + + assert length(members) == 2 + + for member <- members do + assert Map.has_key?(member, "id") + assert Map.has_key?(member, "status") + assert Map.has_key?(member, "profile") + end + + assert_receive {:bypass_request, conn} + assert conn.params == %{"limit" => "200"} + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"] + end + + test "returns error when Okta API is down" do + api_token = Ecto.UUID.generate() + group_id = Ecto.UUID.generate() + + bypass = Bypass.open() + api_base_url = "http://localhost:#{bypass.port}/" + Bypass.down(bypass) + + assert list_group_members(api_base_url, api_token, group_id) == + {:error, %Mint.TransportError{reason: :econnrefused}} + end + end +end diff --git a/elixir/apps/domain/test/domain/auth/adapters/okta/jobs_test.exs b/elixir/apps/domain/test/domain/auth/adapters/okta/jobs_test.exs new file mode 100644 index 000000000..74a9c6a76 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/okta/jobs_test.exs @@ -0,0 +1,838 @@ +defmodule Domain.Auth.Adapters.Okta.JobsTest do + use Domain.DataCase, async: true + alias Domain.{Auth, Actors} + alias Domain.Mocks.OktaDirectory + import Domain.Auth.Adapters.Okta.Jobs + + describe "refresh_access_tokens/1" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_okta_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 is 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_okta_provider(account: account) + + %{ + bypass: bypass, + account: account, + provider: provider + } + end + + test "syncs IdP data", %{provider: provider, bypass: bypass} do + # bypass = Bypass.open(port: bypass.port) + + groups = [ + %{ + "id" => "GROUP_DEVOPS_ID", + "created" => "2024-02-07T04:32:03.000Z", + "lastUpdated" => "2024-02-07T04:32:03.000Z", + "lastMembershipUpdated" => "2024-02-07T04:32:38.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "OKTA_GROUP", + "profile" => %{ + "name" => "DevOps", + "description" => "" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => "http://localhost/md/image.png", + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => "http://localhost/lg/image.png", + "type" => "image/png" + } + ], + "users" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqhvv4IFj2Avg5d7/users" + }, + "apps" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqhvv4IFj2Avg5d7/apps" + } + } + }, + %{ + "id" => "GROUP_ENGINEERING_ID", + "created" => "2024-02-07T04:30:49.000Z", + "lastUpdated" => "2024-02-07T04:30:49.000Z", + "lastMembershipUpdated" => "2024-02-07T04:32:23.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "OKTA_GROUP", + "profile" => %{ + "name" => "Engineering", + "description" => "All of Engineering" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => "http://localhost/md/image.png", + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => "http://localhost/lg/image.png", + "type" => "image/png" + } + ], + "users" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqfqxwa2ohLhp5d7/users" + }, + "apps" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqfqxwa2ohLhp5d7/apps" + } + } + } + ] + + users = [ + %{ + "id" => "USER_JDOE_ID", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-08T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "John", + "lastName" => "Doe", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jdoe@example.com", + "email" => "jdoe@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/OT6AZkcmzkDXwkXcjTHY" + } + } + }, + %{ + "id" => "USER_JSMITH_ID", + "status" => "ACTIVE", + "created" => "2023-10-23T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-11-21T20:04:06.000Z", + "lastLogin" => "2024-02-02T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "Jane", + "lastName" => "Smith", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jsmith@example.com", + "email" => "jsmith@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/I5OsjUZAUVJr4BvNVp3l" + } + } + } + ] + + members = [ + %{ + "id" => "USER_JDOE_ID", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-08T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "John", + "lastName" => "Doe", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jdoe@example.com", + "email" => "jdoe@example.com" + }, + "credentials" => %{ + "password" => %{}, + "emails" => [ + %{ + "value" => "jdoe@example.com", + "status" => "VERIFIED", + "type" => "PRIMARY" + } + ], + "provider" => %{ + "type" => "OKTA", + "name" => "OKTA" + } + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/OT6AZkcmzkDXwkXcjTHY" + } + } + }, + %{ + "id" => "USER_JSMITH_ID", + "status" => "ACTIVE", + "created" => "2023-10-23T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-11-21T20:04:06.000Z", + "lastLogin" => "2024-02-02T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "Jane", + "lastName" => "Smith", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jsmith@example.com", + "email" => "jsmith@example.com" + }, + "credentials" => %{ + "password" => %{}, + "emails" => [ + %{ + "value" => "jsmith@example.com", + "status" => "VERIFIED", + "type" => "PRIMARY" + } + ], + "provider" => %{ + "type" => "OKTA", + "name" => "OKTA" + } + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/I5OsjUZAUVJr4BvNVp3l" + } + } + } + ] + + OktaDirectory.mock_groups_list_endpoint(bypass, groups) + OktaDirectory.mock_users_list_endpoint(bypass, users) + + Enum.each(groups, fn group -> + OktaDirectory.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_ENGINEERING_ID", "G:GROUP_DEVOPS_ID"] + assert group.name in ["Group:Engineering", "Group:DevOps"] + + 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.com"}}, + %{"userinfo" => %{"email" => "jsmith@example.com"}} + ] + + 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", %{bypass: bypass} do + Bypass.down(bypass) + + assert sync_directory(%{}) == :ok + + assert Repo.aggregate(Actors.Group, :count) == 0 + end + + test "updates existing identities and actors", %{ + account: account, + provider: provider, + bypass: bypass + } do + users = [ + %{ + "id" => "USER_JDOE_ID", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-08T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "John", + "lastName" => "Doe", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jdoe@example.com", + "email" => "jdoe@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/OT6AZkcmzkDXwkXcjTHY" + } + } + } + ] + + actor = Fixtures.Actors.create_actor(account: account) + + identity = + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + provider_identifier: "USER_JDOE_ID" + ) + + OktaDirectory.mock_groups_list_endpoint(bypass, []) + OktaDirectory.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.com"} + } + + 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, + bypass: bypass + } do + users = [ + %{ + "id" => "USER_JDOE_ID", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-08T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "John", + "lastName" => "Doe", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jdoe@example.com", + "email" => "jdoe@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/OT6AZkcmzkDXwkXcjTHY" + } + } + }, + %{ + "id" => "USER_JSMITH_ID", + "status" => "ACTIVE", + "created" => "2023-10-23T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-11-21T20:04:06.000Z", + "lastLogin" => "2024-02-02T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "Jane", + "lastName" => "Smith", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jsmith@example.com", + "email" => "jsmith@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/I5OsjUZAUVJr4BvNVp3l" + } + } + } + ] + + groups = [ + %{ + "id" => "GROUP_DEVOPS_ID", + "created" => "2024-02-07T04:32:03.000Z", + "lastUpdated" => "2024-02-07T04:32:03.000Z", + "lastMembershipUpdated" => "2024-02-07T04:32:38.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "OKTA_GROUP", + "profile" => %{ + "name" => "DevOps", + "description" => "" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => "http://localhost/md/image.png", + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => "http://localhost/lg/image.png", + "type" => "image/png" + } + ], + "users" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqhvv4IFj2Avg5d7/users" + }, + "apps" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqhvv4IFj2Avg5d7/apps" + } + } + }, + %{ + "id" => "GROUP_ENGINEERING_ID", + "created" => "2024-02-07T04:30:49.000Z", + "lastUpdated" => "2024-02-07T04:30:49.000Z", + "lastMembershipUpdated" => "2024-02-07T04:32:23.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "OKTA_GROUP", + "profile" => %{ + "name" => "Engineering", + "description" => "All of Engineering" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => "http://localhost/md/image.png", + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => "http://localhost/lg/image.png", + "type" => "image/png" + } + ], + "users" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqfqxwa2ohLhp5d7/users" + }, + "apps" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/groups/00gezqfqxwa2ohLhp5d7/apps" + } + } + } + ] + + one_member = [ + %{ + "id" => "USER_JDOE_ID", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-08T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "John", + "lastName" => "Doe", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jdoe@example.com", + "email" => "jdoe@example.com" + }, + "credentials" => %{ + "password" => %{}, + "emails" => [ + %{ + "value" => "jdoe@example.com", + "status" => "VERIFIED", + "type" => "PRIMARY" + } + ], + "provider" => %{ + "type" => "OKTA", + "name" => "OKTA" + } + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/OT6AZkcmzkDXwkXcjTHY" + } + } + } + ] + + two_members = + one_member ++ + [ + %{ + "id" => "USER_JSMITH_ID", + "status" => "ACTIVE", + "created" => "2023-10-23T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-11-21T20:04:06.000Z", + "lastLogin" => "2024-02-02T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "Jane", + "lastName" => "Smith", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jsmith@example.com", + "email" => "jsmith@example.com" + }, + "credentials" => %{ + "password" => %{}, + "emails" => [ + %{ + "value" => "jsmith@example.com", + "status" => "VERIFIED", + "type" => "PRIMARY" + } + ], + "provider" => %{ + "type" => "OKTA", + "name" => "OKTA" + } + }, + "_links" => %{ + "self" => %{ + "href" => "http://localhost:#{bypass.port}/api/v1/users/I5OsjUZAUVJr4BvNVp3l" + } + } + } + ] + + 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_ENGINEERING_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}") + + OktaDirectory.mock_groups_list_endpoint(bypass, groups) + OktaDirectory.mock_users_list_endpoint(bypass, users) + + OktaDirectory.mock_group_members_list_endpoint( + bypass, + "GROUP_ENGINEERING_ID", + two_members + ) + + OktaDirectory.mock_group_members_list_endpoint( + bypass, + "GROUP_DEVOPS_ID", + one_member + ) + + assert sync_directory(%{}) == :ok + + assert updated_group = Repo.get(Domain.Actors.Group, group.id) + assert updated_group.name == "Group:Engineering" + + assert created_group = + Repo.get_by(Domain.Actors.Group, provider_identifier: "G:GROUP_DEVOPS_ID") + + assert created_group.name == "Group:DevOps" + + 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, bypass: bypass} do + response = %{ + "errorCode" => "E0000011", + "errorSummary" => "Invalid token provided", + "errorLink" => "E0000011", + "errorId" => "sampleU-5P2FZVslkYBMP_Rsq", + "errorCauses" => [] + } + + error_message = "#{response["errorCode"]} => #{response["errorSummary"]}" + + Bypass.expect_once(bypass, "GET", "api/v1/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", "api/v1/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 == "Okta API is temporarily unavailable" + end + end +end diff --git a/elixir/apps/domain/test/domain/auth/adapters/okta_test.exs b/elixir/apps/domain/test/domain/auth/adapters/okta_test.exs new file mode 100644 index 000000000..bd86a58a7 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/okta_test.exs @@ -0,0 +1,300 @@ +defmodule Domain.Auth.Adapters.OktaTest do + use Domain.DataCase, async: true + import Domain.Auth.Adapters.Okta + alias Domain.Auth + alias Domain.Auth.Adapters.OpenIDConnect.PKCE + + describe "identity_changeset/2" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + changeset = %Auth.Identity{} |> Ecto.Changeset.change() + + %{ + bypass: bypass, + account: account, + provider: provider, + changeset: changeset + } + end + + test "puts default provider state", %{provider: provider, changeset: changeset} do + changeset = + Ecto.Changeset.put_change(changeset, :provider_virtual_state, %{ + "userinfo" => %{"email" => "foo@example.com"} + }) + + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + + assert changeset.changes == %{ + provider_virtual_state: %{}, + provider_state: %{"userinfo" => %{"email" => "foo@example.com"}} + } + end + + test "trims provider identifier", %{provider: provider, changeset: changeset} do + changeset = Ecto.Changeset.put_change(changeset, :provider_identifier, " X ") + assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset) + assert changeset.changes.provider_identifier == "X" + end + end + + describe "provider_changeset/1" do + test "returns changeset errors in invalid adapter config" do + changeset = Ecto.Changeset.change(%Auth.Provider{}, %{}) + assert %Ecto.Changeset{} = changeset = provider_changeset(changeset) + assert errors_on(changeset) == %{adapter_config: ["can't be blank"]} + + attrs = Fixtures.Auth.provider_attrs(adapter: :okta, adapter_config: %{}) + changeset = Ecto.Changeset.change(%Auth.Provider{}, attrs) + assert %Ecto.Changeset{} = changeset = provider_changeset(changeset) + + assert errors_on(changeset) == %{ + adapter_config: %{ + client_id: ["can't be blank"], + client_secret: ["can't be blank"], + discovery_document_uri: ["can't be blank"], + api_base_url: ["can't be blank"], + oauth_uri: ["can't be blank"] + } + } + end + + test "returns changeset on valid adapter config" do + account = Fixtures.Accounts.create_account() + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + discovery_document_url = "http://localhost:#{bypass.port}/.well-known/openid-configuration" + oauth_url = "http://localhost:#{bypass.port}/.well-known/oauth-authorization-server" + api_base_url = "http://localhost:#{bypass.port}" + + attrs = + Fixtures.Auth.provider_attrs( + adapter: :okta, + adapter_config: %{ + client_id: "client_id", + client_secret: "client_secret", + discovery_document_uri: discovery_document_url, + oauth_uri: oauth_url, + api_base_url: api_base_url + } + ) + + changeset = Ecto.Changeset.change(%Auth.Provider{account_id: account.id}, attrs) + + assert %Ecto.Changeset{} = changeset = provider_changeset(changeset) + assert {:ok, provider} = Repo.insert(changeset) + + assert provider.name == attrs.name + assert provider.adapter == attrs.adapter + + assert provider.adapter_config == %{ + "scope" => + Enum.join( + [ + "openid", + "email", + "profile", + "offline_access", + "okta.groups.read", + "okta.users.read" + ], + " " + ), + "response_type" => "code", + "client_id" => "client_id", + "client_secret" => "client_secret", + "discovery_document_uri" => discovery_document_url, + "oauth_uri" => oauth_url, + "api_base_url" => api_base_url + } + end + end + + describe "ensure_deprovisioned/1" do + test "does nothing for a provider" do + {provider, _bypass} = Fixtures.Auth.start_and_create_okta_provider() + assert ensure_deprovisioned(provider) == {:ok, provider} + end + end + + describe "verify_and_update_identity/2" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + + %{account: account, provider: provider, identity: identity, bypass: bypass} + end + + test "persists just the id token to adapter state", %{ + provider: provider, + identity: identity, + bypass: bypass + } do + {token, claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert {:ok, identity, expires_at} = verify_and_update_identity(provider, payload) + + assert identity.provider_state == %{ + "access_token" => nil, + "claims" => claims, + "expires_at" => expires_at, + "refresh_token" => nil, + "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" + } + } + end + + test "persists all token details to the adapter state", %{ + 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" => "MY_REFRESH_TOKEN", + "expires_in" => 3600 + }) + + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert {:ok, identity, _expires_at} = verify_and_update_identity(provider, payload) + + assert identity.provider_state["access_token"] == "MY_ACCESS_TOKEN" + assert identity.provider_state["refresh_token"] == "MY_REFRESH_TOKEN" + + assert DateTime.diff(identity.provider_state["expires_at"], DateTime.utc_now()) in 3595..3605 + end + + test "returns error when token is expired", %{ + provider: provider, + identity: identity, + bypass: bypass + } do + forty_seconds_ago = DateTime.utc_now() |> DateTime.add(-40, :second) |> DateTime.to_unix() + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "exp" => forty_seconds_ago + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :expired} + end + + test "returns error when token is invalid", %{ + provider: provider, + bypass: bypass + } do + token = "foo" + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :invalid} + end + + test "returns error when identity does not exist", %{ + identity: identity, + provider: provider, + bypass: bypass + } do + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "sub" => "foo@bar.com" + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{ + "token_type" => "Bearer", + "id_token" => token, + "access_token" => "MY_ACCESS_TOKEN", + "refresh_token" => "MY_REFRESH_TOKEN", + "expires_in" => 3600 + }) + + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :not_found} + end + + test "returns error when identity does not belong to provider", %{ + account: account, + provider: provider, + bypass: bypass + } do + identity = Fixtures.Auth.create_identity(account: account) + + {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" => "MY_REFRESH_TOKEN", + "expires_in" => 3600 + }) + + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :not_found} + end + + test "returns error when provider is down", %{ + provider: provider, + bypass: bypass + } do + Bypass.down(bypass) + + code_verifier = PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + + assert verify_and_update_identity(provider, payload) == {:error, :internal_error} + end + end +end diff --git a/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs b/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs index ec08a7f53..9480ce3c0 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs @@ -76,9 +76,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do adapter_config: %{ client_id: ["can't be blank"], client_secret: ["can't be blank"], - discovery_document_uri: [ - "is invalid, got {:options, {:server_name_indication, []}}" - ] + discovery_document_uri: ["is not a valid URL"] } } end diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 94b3fa27b..4abd73e28 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -13,7 +13,8 @@ defmodule Domain.AuthTest do assert adapters == %{ openid_connect: Domain.Auth.Adapters.OpenIDConnect, google_workspace: Domain.Auth.Adapters.GoogleWorkspace, - microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra + microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra, + okta: Domain.Auth.Adapters.Okta } end end diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 4da2295c5..240e5389b 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -23,6 +23,10 @@ defmodule Domain.Fixtures.Auth do Ecto.UUID.generate() end + def random_provider_identifier(%Domain.Auth.Provider{adapter: :okta}) 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 @@ -115,6 +119,26 @@ defmodule Domain.Fixtures.Auth do {provider, bypass} end + def start_and_create_okta_provider(attrs \\ %{}) do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config = + openid_connect_adapter_config( + api_base_url: "http://localhost:#{bypass.port}", + oauth_uri: "http://localhost:#{bypass.port}/.well-known/oauth-authorization-server", + discovery_document_uri: + "http://localhost:#{bypass.port}/.well-known/openid-configuration", + scope: Domain.Auth.Adapters.Okta.Settings.scope() |> Enum.join(" ") + ) + + provider = + attrs + |> Enum.into(%{adapter_config: adapter_config}) + |> create_okta_provider() + + {provider, bypass} + end + def create_openid_connect_provider(attrs \\ %{}) do attrs = %{ @@ -196,6 +220,33 @@ defmodule Domain.Fixtures.Auth do ) end + def create_okta_provider(attrs \\ %{}) do + attrs = + %{ + adapter: :okta, + 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/okta_directory.ex b/elixir/apps/domain/test/support/mocks/okta_directory.ex new file mode 100644 index 000000000..ac6c7e433 --- /dev/null +++ b/elixir/apps/domain/test/support/mocks/okta_directory.ex @@ -0,0 +1,329 @@ +defmodule Domain.Mocks.OktaDirectory do + @okta_icon_md "https://ok12static.oktacdn.com/assets/img/logos/groups/odyssey/okta-medium.30ce6d4085dff29412984e4c191bc874.png" + @okta_icon_lg "https://ok12static.oktacdn.com/assets/img/logos/groups/odyssey/okta-large.c3cb8cda8ae0add1b4fe928f5844dbe3.png" + + def mock_users_list_endpoint(bypass, users \\ nil) do + users_list_endpoint_path = "api/v1/users" + okta_base_url = "http://localhost:#{bypass.port}" + + resp = + users || + [ + %{ + "id" => "OT6AZkcmzkDXwkXcjTHY", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-08T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "John", + "lastName" => "Doe", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jdoe@example.com", + "email" => "jdoe@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "#{okta_base_url}/api/v1/users/OT6AZkcmzkDXwkXcjTHY" + } + } + }, + %{ + "id" => "I5OsjUZAUVJr4BvNVp3l", + "status" => "ACTIVE", + "created" => "2023-10-23T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-11-21T20:04:06.000Z", + "lastLogin" => "2024-02-02T05:14:25.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{"id" => "otye1rmouoEfu7KCV5d7"}, + "profile" => %{ + "firstName" => "Jane", + "lastName" => "Smith", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "jsmith@example.com", + "email" => "jsmith@example.com" + }, + "_links" => %{ + "self" => %{ + "href" => "#{okta_base_url}/api/v1/users/I5OsjUZAUVJr4BvNVp3l" + } + } + } + ] + + 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) + + bypass + end + + def mock_groups_list_endpoint(bypass, groups \\ nil) do + groups_list_endpoint_path = "api/v1/groups" + okta_base_url = "http://localhost:#{bypass.port}" + + resp = + groups || + [ + %{ + "id" => "00gezqhvv4IFj2Avg5d7", + "created" => "2024-02-07T04:32:03.000Z", + "lastUpdated" => "2024-02-07T04:32:03.000Z", + "lastMembershipUpdated" => "2024-02-07T04:32:38.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "OKTA_GROUP", + "profile" => %{ + "name" => "DevOps", + "description" => "" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => @okta_icon_md, + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => @okta_icon_lg, + "type" => "image/png" + } + ], + "users" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00gezqhvv4IFj2Avg5d7/users" + }, + "apps" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00gezqhvv4IFj2Avg5d7/apps" + } + } + }, + %{ + "id" => "00gezqfqxwa2ohLhp5d7", + "created" => "2024-02-07T04:30:49.000Z", + "lastUpdated" => "2024-02-07T04:30:49.000Z", + "lastMembershipUpdated" => "2024-02-07T04:32:23.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "OKTA_GROUP", + "profile" => %{ + "name" => "Engineering", + "description" => "All of Engineering" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => @okta_icon_md, + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => @okta_icon_lg, + "type" => "image/png" + } + ], + "users" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00gezqfqxwa2ohLhp5d7/users" + }, + "apps" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00gezqfqxwa2ohLhp5d7/apps" + } + } + }, + %{ + "id" => "00ge1rmoufwOX8isq5d7", + "created" => "2023-12-21T18:30:00.000Z", + "lastUpdated" => "2023-12-21T18:30:00.000Z", + "lastMembershipUpdated" => "2024-01-05T16:16:00.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "BUILT_IN", + "profile" => %{ + "name" => "Everyone", + "description" => "All users in your organization" + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => @okta_icon_md, + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => @okta_icon_lg, + "type" => "image/png" + } + ], + "users" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00ge1rmoufwOX8isq5d7/users" + }, + "apps" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00ge1rmoufwOX8isq5d7/apps" + } + } + }, + %{ + "id" => "00ge1rmov9ULMTFSg5d7", + "created" => "2023-12-21T18:30:01.000Z", + "lastUpdated" => "2023-12-21T18:30:01.000Z", + "lastMembershipUpdated" => "2023-12-21T18:30:01.000Z", + "objectClass" => [ + "okta:user_group" + ], + "type" => "BUILT_IN", + "profile" => %{ + "name" => "Okta Administrators", + "description" => + "Okta manages this group, which contains all administrators in your organization." + }, + "_links" => %{ + "logo" => [ + %{ + "name" => "medium", + "href" => @okta_icon_md, + "type" => "image/png" + }, + %{ + "name" => "large", + "href" => @okta_icon_lg, + "type" => "image/png" + } + ], + "users" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00ge1rmov9ULMTFSg5d7/users" + }, + "apps" => %{ + "href" => "#{okta_base_url}/api/v1/groups/00ge1rmov9ULMTFSg5d7/apps" + } + } + } + ] + + 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) + + bypass + end + + def mock_group_members_list_endpoint(bypass, group_id, members \\ nil) do + group_members_list_endpoint_path = "api/v1/groups/#{group_id}/users" + okta_base_url = "http://localhost:#{bypass.port}" + + resp = + members || + [ + %{ + "id" => "00ue1rr3zgV1DjyfL5d7", + "status" => "ACTIVE", + "created" => "2023-12-21T18:30:05.000Z", + "activated" => nil, + "statusChanged" => "2023-12-21T20:04:06.000Z", + "lastLogin" => "2024-02-07T06:05:44.000Z", + "lastUpdated" => "2023-12-21T20:04:06.000Z", + "passwordChanged" => "2023-12-21T20:04:06.000Z", + "type" => %{ + "id" => "otye1rmouoEfu7KCV5d7" + }, + "profile" => %{ + "firstName" => "Brian", + "lastName" => "Manifold", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "bmanifold@firezone.dev", + "email" => "bmanifold@firezone.dev" + }, + "credentials" => %{ + "password" => %{}, + "emails" => [ + %{ + "value" => "bmanifold@firezone.dev", + "status" => "VERIFIED", + "type" => "PRIMARY" + } + ], + "provider" => %{ + "type" => "OKTA", + "name" => "OKTA" + } + }, + "_links" => %{ + "self" => %{ + "href" => "#{okta_base_url}/api/v1/users/00ue1rr3zgV1DjyfL5d7" + } + } + }, + %{ + "id" => "00ueap8xflioRLpKn5d7", + "status" => "ACTIVE", + "created" => "2024-01-05T16:16:00.000Z", + "activated" => "2024-01-05T16:16:00.000Z", + "statusChanged" => "2024-01-05T16:19:01.000Z", + "lastLogin" => "2024-01-05T16:19:10.000Z", + "lastUpdated" => "2024-01-05T16:19:01.000Z", + "passwordChanged" => "2024-01-05T16:19:01.000Z", + "type" => %{ + "id" => "otye1rmouoEfu7KCV5d7" + }, + "profile" => %{ + "firstName" => "Brian", + "lastName" => "Manifold", + "mobilePhone" => nil, + "secondEmail" => nil, + "login" => "bmanifold@gmail.com", + "email" => "bmanifold@gmail.com" + }, + "credentials" => %{ + "password" => %{}, + "emails" => [ + %{ + "value" => "bmanifold@gmail.com", + "status" => "VERIFIED", + "type" => "PRIMARY" + } + ], + "provider" => %{ + "type" => "OKTA", + "name" => "OKTA" + } + }, + "_links" => %{ + "self" => %{ + "href" => "#{okta_base_url}/api/v1/users/00ueap8xflioRLpKn5d7" + } + } + } + ] + + 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) + + bypass + end +end diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index bd34bd8c8..dcbf59413 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -1161,5 +1161,11 @@ defmodule Web.CoreComponents do """ end + def provider_icon(%{adapter: :okta} = assigns) do + ~H""" + Okta Logo + """ + end + def provider_icon(assigns), do: ~H"" end diff --git a/elixir/apps/web/lib/web/components/form_components.ex b/elixir/apps/web/lib/web/components/form_components.ex index 3f74b9acd..605a1b217 100644 --- a/elixir/apps/web/lib/web/components/form_components.ex +++ b/elixir/apps/web/lib/web/components/form_components.ex @@ -32,8 +32,9 @@ defmodule Web.FormComponents do attr :type, :string, default: "text", - values: ~w(checkbox color date datetime-local email file hidden month number password - range radio search group_select select tel text textarea taglist time url week) + values: + ~w(checkbox color date datetime-local email file hidden month number password + range radio readonly search group_select select tel text textarea taglist time url week) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" @@ -230,6 +231,26 @@ defmodule Web.FormComponents do """ end + def input(%{type: "readonly"} = assigns) do + ~H""" +
+ <.label><%= @label %> +
+ <%= assigns.value %> +
+ + + <.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %> +
+ """ + end + def input(%{type: "text", prefix: prefix} = assigns) when not is_nil(prefix) do ~H"""
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 2f252d24e..a9f684025 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 @@ -68,6 +68,118 @@ defmodule Web.Settings.IdentityProviders.Components do """ end + def status( + %{ + provider: %{ + adapter: :microsoft_entra, + adapter_state: %{"refresh_token" => nil, "expires_at" => expires_at}, + disabled_at: nil + } + } = assigns + ) do + assigns = + assign_new(assigns, :expires_at, fn -> + {:ok, dt, _} = DateTime.from_iso8601(expires_at) + dt + end) + + ~H""" +
+ + + No refresh token provided by IdP and access token expires on + <.datetime datetime={@expires_at} /> UTC + +
+ """ + end + + def status( + %{ + provider: %{ + adapter: :microsoft_entra, + disabled_at: disabled_at, + adapter_state: %{"status" => "pending_access_token"} + } + } = assigns + ) + when not is_nil(disabled_at) do + ~H""" +
+ + + Provisioning + + <.button + size="xs" + navigate={ + ~p"/#{@provider.account_id}/settings/identity_providers/microsoft_entra/#{@provider}/redirect" + } + > + Connect IdP + + + +
+ """ + end + + def status( + %{ + provider: %{ + adapter: :okta, + adapter_state: %{"refresh_token" => nil, "expires_at" => expires_at}, + disabled_at: nil + } + } = assigns + ) do + assigns = + assign_new(assigns, :expires_at, fn -> + {:ok, dt, _} = DateTime.from_iso8601(expires_at) + dt + end) + + ~H""" +
+ + + No refresh token provided by IdP and access token expires on + <.datetime datetime={@expires_at} /> UTC + +
+ """ + end + + def status( + %{ + provider: %{ + adapter: :okta, + disabled_at: disabled_at, + adapter_state: %{"status" => "pending_access_token"} + } + } = assigns + ) + when not is_nil(disabled_at) do + ~H""" +
+ + + Provisioning + + <.button + size="xs" + navigate={ + ~p"/#{@provider.account_id}/settings/identity_providers/okta/#{@provider}/redirect" + } + > + Connect IdP + + + +
+ """ + end + def status( %{ provider: %{ @@ -124,6 +236,7 @@ defmodule Web.Settings.IdentityProviders.Components do 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(:okta), do: "Okta" def adapter_name(:openid_connect), do: "OpenID Connect" def view_provider(account, %{adapter: adapter} = provider) @@ -139,6 +252,9 @@ defmodule Web.Settings.IdentityProviders.Components do def view_provider(account, %{adapter: :microsoft_entra} = provider), do: ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}" + def view_provider(account, %{adapter: :okta} = provider), + do: ~p"/#{account}/settings/identity_providers/okta/#{provider}" + def sync_status(%{provider: %{provisioner: :custom}} = assigns) do ~H"""
diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex index c27a7f636..b76f17ad0 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex @@ -52,6 +52,7 @@ defmodule Web.Settings.IdentityProviders.New do <.adapter_item adapter={@adapter} account={@account} + enterprise_feature={true} name="Google Workspace" description="Authenticate users and synchronize users and groups with a custom Google Workspace connector." /> @@ -63,12 +64,25 @@ defmodule Web.Settings.IdentityProviders.New do <.adapter_item adapter={@adapter} account={@account} + enterprise_feature={true} name="Microsoft Entra" description="Authenticate users and synchronize users and groups with a custom Microsoft Entra connector." /> """ end + def adapter(%{adapter: :okta} = assigns) do + ~H""" + <.adapter_item + adapter={@adapter} + account={@account} + enterprise_feature={true} + name="Okta" + description="Authenticate users and synchronize users and groups with a custom Okta connector." + /> + """ + end + def adapter(%{adapter: :openid_connect} = assigns) do ~H""" <.adapter_item @@ -91,6 +105,12 @@ defmodule Web.Settings.IdentityProviders.New do """ end + attr :adapter, :any + attr :account, :any + attr :enterprise_feature, :boolean, default: false + attr :name, :string + attr :description, :string + def adapter_item(assigns) do ~H"""
@@ -107,7 +127,7 @@ defmodule Web.Settings.IdentityProviders.New do - <%= if @adapter == :google_workspace || @adapter == :microsoft_entra do %> + <%= if @enterprise_feature do %> <.badge class="ml-2" type="primary" title="Feature available on the Enterprise plan"> ENTERPRISE @@ -131,4 +151,8 @@ defmodule Web.Settings.IdentityProviders.New do def next_step_path(:microsoft_entra, account) do ~p"/#{account}/settings/identity_providers/microsoft_entra/new" end + + def next_step_path(:okta, account) do + ~p"/#{account}/settings/identity_providers/okta/new" + end end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/components.ex new file mode 100644 index 000000000..3d567153b --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/components.ex @@ -0,0 +1,133 @@ +defmodule Web.Settings.IdentityProviders.Okta.Components do + use Web, :component_library + + def provider_form(assigns) do + ~H""" +
+ <.form for={@form} phx-change={:change} phx-submit={:submit}> + <.step> + <:title>Step 1. Create a new App Integration in Okta + <:content> +

+ 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/okta/#{@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} /> + +
+
+ <.input + label="Name" + autocomplete="off" + field={@form[:name]} + placeholder="Name this identity provider" + required + /> +

+ A friendly name for this identity provider. This will be displayed to end-users. +

+
+ + <.inputs_for :let={adapter_config_form} field={@form[:adapter_config]}> +
+ <.input + label="Client ID" + autocomplete="off" + field={adapter_config_form[:client_id]} + required + /> +

+ The Client ID from the previous step. +

+
+ +
+ <.input + label="Client secret" + autocomplete="off" + field={adapter_config_form[:client_secret]} + required + /> +

+ The Client secret from the previous step. +

+
+ +
+ <.input + label="OAuth Authorization Server URI" + autocomplete="off" + field={adapter_config_form[:oauth_uri]} + placeholder="https://.okta.com/.well-known/oauth-authorization-server" + required + /> +

+ The Metadata URI of the Authorization Server for your Okta Application. +

+
+ +
+ <.input + type="readonly" + label="OIDC well-know configuration URL (readonly)" + field={adapter_config_form[:discovery_document_uri]} + placeholder=".well-known/openid-configuration URL" + /> +

+ The OIDC Configuration URI. This field is derived from the value in the OAuth Authorization Server URI field. +

+
+ +
+ + <.submit_button> + Connect Identity Provider + + + + +
+ """ + end + + def scopes do + Domain.Auth.Adapters.Okta.Settings.scope() + |> Enum.join("\n") + end + + def visible?(value) do + case value do + nil -> false + "" -> false + _ -> true + end + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/connect.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/connect.ex new file mode 100644 index 000000000..7a26bbd10 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/connect.ex @@ -0,0 +1,103 @@ +defmodule Web.Settings.IdentityProviders.Okta.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.Okta + + 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/okta/#{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/okta/#{provider_id}/handle_callback"), + code_verifier, + code + } + + with {:ok, provider} <- + Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject), + {:ok, identity} <- + Okta.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/okta/#{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/okta/#{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/okta/#{provider_id}") + + {:error, :not_found} -> + conn + |> put_flash(:error, "Provider does not exist.") + |> redirect(to: ~p"/#{account}/settings/identity_providers/okta/#{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/okta/#{provider_id}") + + {:error, _reason} -> + conn + |> put_flash(:error, "You may not authenticate to this account.") + |> redirect(to: ~p"/#{account}/settings/identity_providers/okta/#{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/okta/#{provider_id}") + end + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex new file mode 100644 index 000000000..c2be08402 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/edit.ex @@ -0,0 +1,95 @@ +defmodule Web.Settings.IdentityProviders.Okta.Edit do + use Web, :live_view + import Web.Settings.IdentityProviders.Okta.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 path={~p"/#{@account}/settings/identity_providers/okta/#{@form.data}/edit"}> + Edit <%= # {@form.data.name} %> + + + <.section> + <:title> + Edit Identity Provider <%= @form.data.name %> + + <:content> + <.provider_form account={@account} id={@form.data.id} form={@form} /> + + + """ + end + + def handle_event("change", %{"provider" => attrs}, socket) do + attrs = + attrs + |> put_discovery_document_uri() + + 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 + attrs = + attrs + |> Map.update("adapter_config", %{}, &put_api_base_url/1) + + 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/okta/#{provider}/redirect" + ) + + {:noreply, socket} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp put_api_base_url(adapter_config) do + uri = URI.parse(adapter_config["discovery_document_uri"]) + Map.put(adapter_config, "api_base_url", "#{uri.scheme}://#{uri.host}") + end + + defp put_discovery_document_uri(attrs) do + config = attrs["adapter_config"] + + oidc_uri = + String.replace_suffix( + config["oauth_uri"], + "oauth-authorization-server", + "openid-configuration" + ) + + config = Map.put(config, "discovery_document_uri", oidc_uri) + + Map.put(attrs, "adapter_config", config) + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/new.ex new file mode 100644 index 000000000..2540ee5b8 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/new.ex @@ -0,0 +1,116 @@ +defmodule Web.Settings.IdentityProviders.Okta.New do + use Web, :live_view + import Web.Settings.IdentityProviders.Okta.Components + alias Domain.Auth + + def mount(_params, _session, socket) do + id = Ecto.UUID.generate() + + changeset = + Auth.new_provider(socket.assigns.account, %{ + name: "Okta", + adapter: :okta, + 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 path={~p"/#{@account}/settings/identity_providers/new"}> + Create Identity Provider + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/okta/new"}> + Okta + + + <.section> + <:title> + Add a new Okta Identity Provider + + <:help> + For a more detailed guide on setting up Firezone with Okta, please <.link class={link_style()}>refer to our documentation. + + <:content> + <.provider_form account={@account} id={@id} form={@form} /> + + + """ + end + + def handle_event("change", %{"provider" => attrs}, socket) do + attrs = + attrs + |> Map.put("adapter", :okta) + |> put_discovery_document_uri() + + 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.update("adapter_config", %{}, &put_api_base_url/1) + |> Map.put("id", socket.assigns.id) + |> Map.put("adapter", :okta) + # 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/okta/#{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 + + defp put_api_base_url(adapter_config) do + uri = URI.parse(adapter_config["discovery_document_uri"]) + Map.put(adapter_config, "api_base_url", "#{uri.scheme}://#{uri.host}") + end + + defp put_discovery_document_uri(attrs) do + config = attrs["adapter_config"] + + oidc_uri = + String.replace_suffix( + config["oauth_uri"], + "oauth-authorization-server", + "openid-configuration" + ) + + config = Map.put(config, "discovery_document_uri", oidc_uri) + + Map.put(attrs, "adapter_config", config) + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex new file mode 100644 index 000000000..88cdfcfe1 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/okta/show.ex @@ -0,0 +1,209 @@ +defmodule Web.Settings.IdentityProviders.Okta.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 path={~p"/#{@account}/settings/identity_providers/okta/#{@provider}"}> + <%= @provider.name %> + + + + <.section> + <:title> + Identity Provider <%= @provider.name %> + (disabled) + (deleted) + + <:action :if={is_nil(@provider.deleted_at)}> + <.edit_button navigate={ + ~p"/#{@account}/settings/identity_providers/okta/#{@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/okta/#{@provider}/redirect"} + icon="hero-arrow-path" + > + Reconnect + + + <:content> + <.header> + <:title>Details + + + <.flash_group flash={@flash} /> + +
+ <.vertical_table id="provider"> + <.vertical_table_row> + <:label>Name + <:value><%= @provider.name %> + + <.vertical_table_row> + <:label>Status + <:value> + <.status provider={@provider} /> + + + + <.vertical_table_row> + <:label>Sync Status + <: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} + /> +
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" + > +

+ IdP provider reported an error during the last sync: +

+
+ <%= @provider.last_sync_error %> +
+
+ + + + <.vertical_table_row> + <:label>Client ID + <:value><%= @provider.adapter_config["client_id"] %> + + <.vertical_table_row> + <:label>Callback URLs + <:value> + <.code_block + :for={ + {type, redirect_url} <- [ + sign_in: + url(~p"/#{@account.id}/sign_in/providers/#{@provider.id}/handle_callback"), + connect: + url( + ~p"/#{@account.id}/settings/identity_providers/okta/#{@provider.id}/handle_callback" + ) + ] + } + id={"redirect_url-#{type}"} + class="w-full mb-4 text-xs whitespace-nowrap rounded" + phx-no-format + ><%= redirect_url %> + + + <.vertical_table_row> + <:label>Created + <:value> + <.created_by account={@account} schema={@provider} /> + + + +
+ + + + <.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 + + + <:content> + + """ + 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 diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index c3f31e9c8..a50461609 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -239,6 +239,16 @@ defmodule Web.Router do get "/:provider_id/handle_callback", Connect, :handle_idp_callback end + scope "/okta", Okta 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 diff --git a/elixir/apps/web/priv/static/images/okta-logo.svg b/elixir/apps/web/priv/static/images/okta-logo.svg new file mode 100644 index 000000000..a2a81c061 --- /dev/null +++ b/elixir/apps/web/priv/static/images/okta-logo.svg @@ -0,0 +1,14 @@ + + + + diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/connect_test.exs new file mode 100644 index 000000000..d00f922df --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/connect_test.exs @@ -0,0 +1,317 @@ +defmodule Web.Live.Settings.IdentityProviders.MicrosoftEntra.Connect do + use Web.ConnCase, async: true + + describe "redirect_to_idp/2" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity, + subject: subject + } + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get( + ~p"/#{account_id}/settings/identity_providers/microsoft_entra/#{provider_id}/redirect" + ) + + assert redirected_to(conn) =~ ~p"/#{account_id}" + assert flash(conn, :error) == "You must sign in to access this page." + end + + test "redirects with an error when provider does not exist", %{identity: identity, conn: conn} do + account = Fixtures.Accounts.create_account() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider_id}/redirect" + ) + + assert redirected_to(conn) == ~p"/#{account}/settings/identity_providers" + assert flash(conn, :error) == "Provider does not exist." + end + + test "redirects to IdP when provider exists", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider.id}/redirect", + %{} + ) + + assert to = redirected_to(conn) + uri = URI.parse(to) + assert uri.host == "localhost" + assert uri.path == "/authorize" + + callback_url = + url( + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider.id}/handle_callback" + ) + + {_params, state, verifier} = + conn.cookies["fz_auth_state_#{provider.id}"] + |> :erlang.binary_to_term() + + code_challenge = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_challenge(verifier) + scope_string = ~w[ + openid + email + profile + offline_access + Group.Read.All + GroupMember.Read.All + User.Read + User.Read.All + ] |> Enum.join(" ") + + assert URI.decode_query(uri.query) == %{ + "access_type" => "offline", + "client_id" => provider.adapter_config["client_id"], + "code_challenge" => code_challenge, + "code_challenge_method" => "S256", + "redirect_uri" => callback_url, + "response_type" => "code", + "scope" => scope_string, + "state" => state, + "prompt" => "consent" + } + end + end + + describe "handle_idp_callback/2" do + setup do + account = Fixtures.Accounts.create_account() + %{account: account} + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback", %{ + "state" => "foo", + "code" => "bar" + }) + + assert redirected_to(conn) == "/#{account_id}" + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "redirects with an error when state cookie does not exist", %{ + account: account, + conn: conn + } do + {provider, _bypass} = + Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/handle_callback", + %{ + "state" => "XOXOX", + "code" => "bar" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}" + + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "resets the sync error when IdP is reconnected", %{ + account: account, + conn: conn + } do + {provider, bypass} = + Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account) + + provider = Fixtures.Auth.fail_provider_sync(provider) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/redirect", + %{} + ) + + sub = Ecto.UUID.generate() + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => Ecto.UUID.generate(), + "sub" => sub + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass, %{"sub" => sub}) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + + {_params, state, _verifier} = + redirected_conn.cookies[cookie_key] + |> :erlang.binary_to_term([:safe]) + + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}" + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + assert provider.last_sync_error == nil + assert provider.last_syncs_failed == 0 + end + + test "redirects to the actors index when credentials are valid and return path is empty", %{ + account: account, + conn: conn + } do + {provider, bypass} = + Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/redirect", + %{} + ) + + sub = Ecto.UUID.generate() + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => Ecto.UUID.generate(), + "sub" => sub + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + Mocks.OpenIDConnect.expect_userinfo(bypass, %{ + "sub" => sub + }) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + + {_params, state, _verifier} = + redirected_conn.cookies[cookie_key] + |> :erlang.binary_to_term([:safe]) + + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}" + + assert %{ + "preferred_locale" => "en_US", + "sessions" => [{_account_id, _logged_in_at, session_token}] + } = conn.private.plug_session + + context = %Domain.Auth.Context{ + type: :browser, + remote_ip: conn.remote_ip, + user_agent: conn.assigns.user_agent, + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 37.7749, + remote_ip_location_lon: -120.4194 + } + + assert {:ok, subject} = Domain.Auth.authenticate(session_token, context) + assert subject.identity.id == identity.id + assert subject.identity.last_seen_user_agent == context.user_agent + assert subject.identity.last_seen_remote_ip.address == context.remote_ip + assert subject.identity.last_seen_at + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + + assert %{ + "access_token" => _, + "claims" => %{}, + "expires_at" => _, + "refresh_token" => _, + "userinfo" => %{} + } = provider.adapter_state + + assert is_nil(provider.disabled_at) + end + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/edit_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/edit_test.exs new file mode 100644 index 000000000..6575bc994 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/edit_test.exs @@ -0,0 +1,167 @@ +defmodule Web.Live.Settings.IdentityProviders.MicrosoftEntra.EditTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + path = ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/edit" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/edit") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[adapter_config][discovery_document_uri]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :microsoft_entra, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/edit") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + render_submit(form) + assert provider = Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert_redirected( + lv, + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider}/redirect" + ) + + assert provider.name == provider_attrs.name + assert provider.adapter == :microsoft_entra + + assert provider.adapter_config["client_id"] == adapter_config_attrs["client_id"] + assert provider.adapter_config["client_secret"] == adapter_config_attrs["client_secret"] + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :microsoft_entra, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}/edit") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + changed_values = %{ + provider: %{ + name: String.duplicate("a", 256), + adapter_config: %{provider_attrs.adapter_config | "client_id" => ""} + } + } + + validate_change(form, changed_values, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"], + "provider[adapter_config][client_id]" => ["can't be blank"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/new_test.exs new file mode 100644 index 000000000..d40b4198a --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/new_test.exs @@ -0,0 +1,162 @@ +defmodule Web.Live.Settings.IdentityProviders.MicrosoftEntra.NewTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + + %{ + account: account, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + conn: conn + } do + path = ~p"/#{account}/settings/identity_providers/microsoft_entra/new" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/new") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[adapter_config][discovery_document_uri]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :microsoft_entra, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/new") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + render_submit(form) + assert provider = Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert_redirected( + lv, + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider}/redirect" + ) + + assert provider.name == provider_attrs.name + assert provider.adapter == :microsoft_entra + + assert provider.adapter_config["client_id"] == + provider_attrs.adapter_config["client_id"] + + assert provider.adapter_config["client_secret"] == + provider_attrs.adapter_config["client_secret"] + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :microsoft_entra, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/new") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + changed_values = %{ + provider: %{ + name: String.duplicate("a", 256), + adapter_config: %{provider_attrs.adapter_config | "client_id" => ""} + } + } + + validate_change(form, changed_values, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"], + "provider[adapter_config][client_id]" => ["can't be blank"], + "provider[adapter_config][discovery_document_uri]" => ["can't be blank"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/show_test.exs new file mode 100644 index 000000000..a41d7819e --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/microsoft_entra/show_test.exs @@ -0,0 +1,288 @@ +defmodule Web.Live.Settings.IdentityProviders.MicrosoftEntra.ShowTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + + {provider, bypass} = + Fixtures.Auth.start_and_create_microsoft_entra_provider(account: account) + + %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + bypass: bypass + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + path = ~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders deleted provider without action buttons", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider = Fixtures.Auth.delete_provider(provider) + + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert html =~ "(deleted)" + assert active_buttons(html) == [] + end + + test "renders breadcrumbs item", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert item = Floki.find(html, "[aria-label='Breadcrumb']") + breadcrumbs = String.trim(Floki.text(item)) + assert breadcrumbs =~ "Identity Providers Settings" + assert breadcrumbs =~ provider.name + end + + test "renders provider details", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["name"] == provider.name + assert table["status"] == "Active" + assert table["sync status"] == "Never synced" + assert table["client id"] == provider.adapter_config["client_id"] + assert around_now?(table["created"]) + end + + test "renders sync status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider = Fixtures.Auth.fail_provider_sync(provider) + Fixtures.Auth.create_identity(account: account, provider: provider) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["sync status"] =~ provider.last_sync_error + + provider = Fixtures.Auth.finish_provider_sync(provider) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["sync status"] =~ "Synced 1 identity and 0 groups" + end + + test "renders name of actor that created provider", %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + conn: conn + } do + provider + |> Ecto.Changeset.change( + created_by: :identity, + created_by_identity_id: identity.id + ) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + |> Map.fetch!("created") =~ "by #{actor.name}" + end + + test "renders provider status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider + |> Ecto.Changeset.change(disabled_at: DateTime.utc_now()) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + |> Map.fetch!("status") == "Disabled" + + provider + |> Ecto.Changeset.change( + name: "BLAH", + disabled_at: DateTime.utc_now(), + adapter_state: %{"status" => "pending_access_token"} + ) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + |> Map.fetch!("status") == "Provisioning Connect IdP" + end + + test "disables status while pending for access token", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider + |> Ecto.Changeset.change( + disabled_at: DateTime.utc_now(), + adapter_state: %{"status" => "pending_access_token"} + ) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + refute lv |> element("button", "Enable Identity Provider") |> has_element?() + refute lv |> element("button", "Disable Identity Provider") |> has_element?() + end + + test "allows changing provider status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert lv + |> element("button", "Disable") + |> render_click() + |> Floki.find("#provider") + |> vertical_table_to_map() + |> Map.fetch!("status") == "Disabled" + + assert lv + |> element("button", "Enable") + |> render_click() + |> Floki.find("#provider") + |> vertical_table_to_map() + |> Map.fetch!("status") == "Active" + end + + test "allows deleting identity providers", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + lv + |> element("button", "Delete Identity Provider") + |> render_click() + + assert_redirected(lv, ~p"/#{account}/settings/identity_providers") + + assert Repo.get(Domain.Auth.Provider, provider.id).deleted_at + end + + test "allows reconnecting identity providers", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/microsoft_entra/#{provider}") + + assert lv + |> element("a", "Reconnect") + |> render() + |> Floki.attribute("href") + |> hd() == + ~p"/#{account.id}/settings/identity_providers/microsoft_entra/#{provider}/redirect" + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs index 618ebb0b9..4b6568220 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs @@ -47,6 +47,12 @@ defmodule Web.Live.Settings.IdentityProviders.NewTest do assert html =~ "Feature available on the Enterprise plan" assert html =~ "ENTERPRISE" + assert has_element?(lv, "#idp-option-microsoft_entra") + assert html =~ "Microsoft Entra" + + assert has_element?(lv, "#idp-option-okta") + assert html =~ "Okta" + assert has_element?(lv, "#idp-option-openid_connect") assert html =~ "OpenID Connect" end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/okta/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/okta/connect_test.exs new file mode 100644 index 000000000..2924a1df3 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/okta/connect_test.exs @@ -0,0 +1,309 @@ +defmodule Web.Live.Settings.IdentityProviders.Okta.Connect do + use Web.ConnCase, async: true + + describe "redirect_to_idp/2" do + setup do + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_okta_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity, + subject: subject + } + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get(~p"/#{account_id}/settings/identity_providers/okta/#{provider_id}/redirect") + + assert redirected_to(conn) =~ ~p"/#{account_id}" + assert flash(conn, :error) == "You must sign in to access this page." + end + + test "redirects with an error when provider does not exist", %{identity: identity, conn: conn} do + account = Fixtures.Accounts.create_account() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get(~p"/#{account.id}/settings/identity_providers/okta/#{provider_id}/redirect") + + assert redirected_to(conn) == ~p"/#{account}/settings/identity_providers" + assert flash(conn, :error) == "Provider does not exist." + end + + test "redirects to IdP when provider exists", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/okta/#{provider.id}/redirect", + %{} + ) + + assert to = redirected_to(conn) + uri = URI.parse(to) + assert uri.host == "localhost" + assert uri.path == "/authorize" + + callback_url = + url(~p"/#{account.id}/settings/identity_providers/okta/#{provider.id}/handle_callback") + + {_params, state, verifier} = + conn.cookies["fz_auth_state_#{provider.id}"] + |> :erlang.binary_to_term() + + code_challenge = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_challenge(verifier) + scope_string = ~w[ + openid + email + profile + offline_access + okta.groups.read + okta.users.read + ] |> Enum.join(" ") + + assert URI.decode_query(uri.query) == %{ + "access_type" => "offline", + "client_id" => provider.adapter_config["client_id"], + "code_challenge" => code_challenge, + "code_challenge_method" => "S256", + "redirect_uri" => callback_url, + "response_type" => "code", + "scope" => scope_string, + "state" => state, + "prompt" => "consent" + } + end + end + + describe "handle_idp_callback/2" do + setup do + account = Fixtures.Accounts.create_account() + %{account: account} + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback", %{ + "state" => "foo", + "code" => "bar" + }) + + assert redirected_to(conn) == "/#{account_id}" + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "redirects with an error when state cookie does not exist", %{ + account: account, + conn: conn + } do + {provider, _bypass} = + Fixtures.Auth.start_and_create_okta_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/okta/#{provider}/handle_callback", + %{ + "state" => "XOXOX", + "code" => "bar" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/okta/#{provider}" + + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "resets the sync error when IdP is reconnected", %{ + account: account, + conn: conn + } do + {provider, bypass} = + Fixtures.Auth.start_and_create_okta_provider(account: account) + + provider = Fixtures.Auth.fail_provider_sync(provider) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/okta/#{provider}/redirect", + %{} + ) + + sub = Ecto.UUID.generate() + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => Ecto.UUID.generate(), + "sub" => sub + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass, %{"sub" => sub}) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + + {_params, state, _verifier} = + redirected_conn.cookies[cookie_key] + |> :erlang.binary_to_term([:safe]) + + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/okta/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/okta/#{provider}" + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + assert provider.last_sync_error == nil + assert provider.last_syncs_failed == 0 + end + + test "redirects to the actors index when credentials are valid and return path is empty", %{ + account: account, + conn: conn + } do + {provider, bypass} = + Fixtures.Auth.start_and_create_okta_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/okta/#{provider}/redirect", + %{} + ) + + sub = Ecto.UUID.generate() + + {token, _claims} = + Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{ + "oid" => Ecto.UUID.generate(), + "sub" => sub + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + + Mocks.OpenIDConnect.expect_userinfo(bypass, %{ + "sub" => sub + }) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + + {_params, state, _verifier} = + redirected_conn.cookies[cookie_key] + |> :erlang.binary_to_term([:safe]) + + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/okta/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/okta/#{provider}" + + assert %{ + "preferred_locale" => "en_US", + "sessions" => [{_account_id, _logged_in_at, session_token}] + } = conn.private.plug_session + + context = %Domain.Auth.Context{ + type: :browser, + remote_ip: conn.remote_ip, + user_agent: conn.assigns.user_agent, + remote_ip_location_region: "Mexico", + remote_ip_location_city: "Merida", + remote_ip_location_lat: 37.7749, + remote_ip_location_lon: -120.4194 + } + + assert {:ok, subject} = Domain.Auth.authenticate(session_token, context) + assert subject.identity.id == identity.id + assert subject.identity.last_seen_user_agent == context.user_agent + assert subject.identity.last_seen_remote_ip.address == context.remote_ip + assert subject.identity.last_seen_at + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + + assert %{ + "access_token" => _, + "claims" => %{}, + "expires_at" => _, + "refresh_token" => _, + "userinfo" => %{} + } = provider.adapter_state + + assert is_nil(provider.disabled_at) + end + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/okta/edit_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/okta/edit_test.exs new file mode 100644 index 000000000..f71ac33da --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/okta/edit_test.exs @@ -0,0 +1,182 @@ +defmodule Web.Live.Settings.IdentityProviders.Okta.EditTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = Fixtures.Accounts.create_account() + + {provider, bypass} = + Fixtures.Auth.start_and_create_okta_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + path = ~p"/#{account}/settings/identity_providers/okta/#{provider}/edit" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}/edit") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[adapter_config][discovery_document_uri]", + "provider[adapter_config][oauth_uri]", + "provider[name]" + ] + end + + test "edits an existing provider on valid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + oauth_uri: "http://localhost:#{bypass.port}/.well-known/oauth-authorization-server" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :okta, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}/edit") + + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + |> render_submit(%{ + provider: %{ + adapter_config: %{ + "discovery_document_uri" => + "http://localhost:#{bypass.port}/.well-known/openid-configuration" + } + } + }) + + assert provider = Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert_redirected( + lv, + ~p"/#{account.id}/settings/identity_providers/okta/#{provider}/redirect" + ) + + assert provider.name == provider_attrs.name + assert provider.adapter == :okta + + assert provider.adapter_config["client_id"] == adapter_config_attrs["client_id"] + assert provider.adapter_config["client_secret"] == adapter_config_attrs["client_secret"] + + assert provider.adapter_config["oauth_uri"] == + "http://localhost:#{bypass.port}/.well-known/oauth-authorization-server" + + assert provider.adapter_config["discovery_document_uri"] == + "http://localhost:#{bypass.port}/.well-known/openid-configuration" + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + oauth_uri: "http://localhost:#{bypass.port}/.well-known/oauth-authorization-server" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :okta, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}/edit") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + changed_values = %{ + provider: %{ + name: String.duplicate("a", 256), + adapter_config: %{provider_attrs.adapter_config | "client_id" => ""} + } + } + + validate_change(form, changed_values, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"], + "provider[adapter_config][client_id]" => ["can't be blank"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/okta/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/okta/new_test.exs new file mode 100644 index 000000000..26898d2ce --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/okta/new_test.exs @@ -0,0 +1,169 @@ +defmodule Web.Live.Settings.IdentityProviders.Okta.NewTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + + %{ + account: account, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + conn: conn + } do + path = ~p"/#{account}/settings/identity_providers/okta/new" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/new") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[adapter_config][oauth_uri]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + oauth_uri: "http://localhost:#{bypass.port}/.well-known/oauth-authorization-server" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :okta, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/new") + + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + |> render_submit(%{ + provider: %{ + adapter_config: %{ + "discovery_document_uri" => + "http://localhost:#{bypass.port}/.well-known/openid-configuration" + } + } + }) + + assert provider = Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert_redirected( + lv, + ~p"/#{account.id}/settings/identity_providers/okta/#{provider}/redirect" + ) + + assert provider.name == provider_attrs.name + assert provider.adapter == :okta + + assert provider.adapter_config["client_id"] == + provider_attrs.adapter_config["client_id"] + + assert provider.adapter_config["client_secret"] == + provider_attrs.adapter_config["client_secret"] + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + bypass = Domain.Mocks.OpenIDConnect.discovery_document_server() + + adapter_config_attrs = + Fixtures.Auth.openid_connect_adapter_config( + discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration" + ) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + Fixtures.Auth.provider_attrs( + adapter: :okta, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/new") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + changed_values = %{ + provider: %{ + name: String.duplicate("a", 256), + adapter_config: %{provider_attrs.adapter_config | "client_id" => ""} + } + } + + validate_change(form, changed_values, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"], + "provider[adapter_config][client_id]" => ["can't be blank"], + "provider[adapter_config][oauth_uri]" => ["can't be blank"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/okta/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/okta/show_test.exs new file mode 100644 index 000000000..3a0a0580d --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/okta/show_test.exs @@ -0,0 +1,288 @@ +defmodule Web.Live.Settings.IdentityProviders.Okta.ShowTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + + {provider, bypass} = + Fixtures.Auth.start_and_create_okta_provider(account: account) + + %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + bypass: bypass + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + path = ~p"/#{account}/settings/identity_providers/okta/#{provider}" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders deleted provider without action buttons", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider = Fixtures.Auth.delete_provider(provider) + + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert html =~ "(deleted)" + assert active_buttons(html) == [] + end + + test "renders breadcrumbs item", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert item = Floki.find(html, "[aria-label='Breadcrumb']") + breadcrumbs = String.trim(Floki.text(item)) + assert breadcrumbs =~ "Identity Providers Settings" + assert breadcrumbs =~ provider.name + end + + test "renders provider details", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["name"] == provider.name + assert table["status"] == "Active" + assert table["sync status"] == "Never synced" + assert table["client id"] == provider.adapter_config["client_id"] + assert around_now?(table["created"]) + end + + test "renders sync status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider = Fixtures.Auth.fail_provider_sync(provider) + Fixtures.Auth.create_identity(account: account, provider: provider) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["sync status"] =~ provider.last_sync_error + + provider = Fixtures.Auth.finish_provider_sync(provider) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["sync status"] =~ "Synced 1 identity and 0 groups" + end + + test "renders name of actor that created provider", %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + conn: conn + } do + provider + |> Ecto.Changeset.change( + created_by: :identity, + created_by_identity_id: identity.id + ) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + |> Map.fetch!("created") =~ "by #{actor.name}" + end + + test "renders provider status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider + |> Ecto.Changeset.change(disabled_at: DateTime.utc_now()) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + |> Map.fetch!("status") == "Disabled" + + provider + |> Ecto.Changeset.change( + name: "BLAH", + disabled_at: DateTime.utc_now(), + adapter_state: %{"status" => "pending_access_token"} + ) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + |> Map.fetch!("status") == "Provisioning Connect IdP" + end + + test "disables status while pending for access token", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider + |> Ecto.Changeset.change( + disabled_at: DateTime.utc_now(), + adapter_state: %{"status" => "pending_access_token"} + ) + |> Repo.update!() + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + refute lv |> element("button", "Enable Identity Provider") |> has_element?() + refute lv |> element("button", "Disable Identity Provider") |> has_element?() + end + + test "allows changing provider status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert lv + |> element("button", "Disable") + |> render_click() + |> Floki.find("#provider") + |> vertical_table_to_map() + |> Map.fetch!("status") == "Disabled" + + assert lv + |> element("button", "Enable") + |> render_click() + |> Floki.find("#provider") + |> vertical_table_to_map() + |> Map.fetch!("status") == "Active" + end + + test "allows deleting identity providers", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + lv + |> element("button", "Delete Identity Provider") + |> render_click() + + assert_redirected(lv, ~p"/#{account}/settings/identity_providers") + + assert Repo.get(Domain.Auth.Provider, provider.id).deleted_at + end + + test "allows reconnecting identity providers", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/okta/#{provider}") + + assert lv + |> element("a", "Reconnect") + |> render() + |> Floki.attribute("href") + |> hd() == + ~p"/#{account.id}/settings/identity_providers/okta/#{provider}/redirect" + end +end diff --git a/elixir/config/config.exs b/elixir/config/config.exs index da5f26391..89c170d5a 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -51,6 +51,8 @@ config :domain, Domain.Auth.Adapters.MicrosoftEntra.APIClient, endpoint: "https://graph.microsoft.com", finch_transport_opts: [] +config :domain, Domain.Auth.Adapters.Okta.APIClient, finch_transport_opts: [] + config :domain, platform_adapter: nil config :domain, Domain.GoogleCloudPlatform, diff --git a/terraform/environments/production/main.tf b/terraform/environments/production/main.tf index 5ce04aa46..4f8aa99ca 100644 --- a/terraform/environments/production/main.tf +++ b/terraform/environments/production/main.tf @@ -456,7 +456,7 @@ locals { # Auth { name = "AUTH_PROVIDER_ADAPTERS" - value = "email,openid_connect,google_workspace,token,microsoft_entra" + value = "email,openid_connect,google_workspace,token,microsoft_entra,okta" }, # Registry from which Docker install scripts pull from { diff --git a/terraform/environments/staging/main.tf b/terraform/environments/staging/main.tf index 91c187e10..8b9f7a4cf 100644 --- a/terraform/environments/staging/main.tf +++ b/terraform/environments/staging/main.tf @@ -408,7 +408,7 @@ locals { # Auth { name = "AUTH_PROVIDER_ADAPTERS" - value = "email,openid_connect,google_workspace,token,microsoft_entra" + value = "email,openid_connect,google_workspace,token,microsoft_entra,okta" }, # Registry from which Docker install scripts pull from {