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"""
+
+ """
+ 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"""
+
+ 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} /> + ++ A friendly name for this identity provider. This will be displayed to end-users. +
++ The Client ID from the previous step. +
++ The Client secret from the previous step. +
++ The Metadata URI of the Authorization Server for your Okta Application. +
++ The OIDC Configuration URI. This field is derived from the value in the OAuth Authorization Server URI field. +
+<%= @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} />
+
+ + IdP provider reported an error during the last sync: +
+<%= @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} />
+
+
+
+