mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
Add Okta directory sync (#3614)
Why: * To allow syncing of users/groups/memberships from an IDP to Firezone, a custom identify provider adapter needs to be created in the portal codebase at this time. The custom IDP adapter created in this commit is for Okta. * This commit also includes some additional tests for the Microsoft Entra IDP adapter. These tests were mistakenly overlooked when finishing the Entra adapter.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
82
elixir/apps/domain/lib/domain/auth/adapters/okta.ex
Normal file
82
elixir/apps/domain/lib/domain/auth/adapters/okta.ex
Normal file
@@ -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
|
||||
149
elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex
Normal file
149
elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex
Normal file
@@ -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
|
||||
173
elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex
Normal file
173
elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex
Normal file
@@ -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
|
||||
23
elixir/apps/domain/lib/domain/auth/adapters/okta/settings.ex
Normal file
23
elixir/apps/domain/lib/domain/auth/adapters/okta/settings.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
##############################################
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
838
elixir/apps/domain/test/domain/auth/adapters/okta/jobs_test.exs
Normal file
838
elixir/apps/domain/test/domain/auth/adapters/okta/jobs_test.exs
Normal file
@@ -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
|
||||
300
elixir/apps/domain/test/domain/auth/adapters/okta_test.exs
Normal file
300
elixir/apps/domain/test/domain/auth/adapters/okta_test.exs
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
329
elixir/apps/domain/test/support/mocks/okta_directory.ex
Normal file
329
elixir/apps/domain/test/support/mocks/okta_directory.ex
Normal file
@@ -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
|
||||
@@ -1161,5 +1161,11 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
def provider_icon(%{adapter: :okta} = assigns) do
|
||||
~H"""
|
||||
<img src={~p"/images/okta-logo.svg"} alt="Okta Logo" {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
def provider_icon(assigns), do: ~H""
|
||||
end
|
||||
|
||||
@@ -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"""
|
||||
<div>
|
||||
<.label><%= @label %></.label>
|
||||
<div class="border border-solid rounded p-2 text-sm text-neutral-500">
|
||||
<%= assigns.value %>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
{@rest}
|
||||
/>
|
||||
|
||||
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "text", prefix: prefix} = assigns) when not is_nil(prefix) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
|
||||
@@ -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"""
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
<span class="ml-3">
|
||||
No refresh token provided by IdP and access token expires on
|
||||
<.datetime datetime={@expires_at} /> UTC
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
<span class="ml-3">
|
||||
Provisioning
|
||||
<span :if={@provider.adapter_state["status"]}>
|
||||
<.button
|
||||
size="xs"
|
||||
navigate={
|
||||
~p"/#{@provider.account_id}/settings/identity_providers/microsoft_entra/#{@provider}/redirect"
|
||||
}
|
||||
>
|
||||
Connect IdP
|
||||
</.button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
<span class="ml-3">
|
||||
No refresh token provided by IdP and access token expires on
|
||||
<.datetime datetime={@expires_at} /> UTC
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
<span class="ml-3">
|
||||
Provisioning
|
||||
<span :if={@provider.adapter_state["status"]}>
|
||||
<.button
|
||||
size="xs"
|
||||
navigate={
|
||||
~p"/#{@provider.account_id}/settings/identity_providers/okta/#{@provider}/redirect"
|
||||
}
|
||||
>
|
||||
Connect IdP
|
||||
</.button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div :if={not is_nil(@provider.last_synced_at)} class="flex items-center">
|
||||
|
||||
@@ -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"""
|
||||
<div>
|
||||
@@ -107,7 +127,7 @@ defmodule Web.Settings.IdentityProviders.New do
|
||||
<label for={"idp-option-#{@adapter}"} class="block ml-2 text-lg text-neutral-900">
|
||||
<%= @name %>
|
||||
</label>
|
||||
<%= if @adapter == :google_workspace || @adapter == :microsoft_entra do %>
|
||||
<%= if @enterprise_feature do %>
|
||||
<.badge class="ml-2" type="primary" title="Feature available on the Enterprise plan">
|
||||
ENTERPRISE
|
||||
</.badge>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
defmodule Web.Settings.IdentityProviders.Okta.Components do
|
||||
use Web, :component_library
|
||||
|
||||
def provider_form(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-12">
|
||||
<.form for={@form} phx-change={:change} phx-submit={:submit}>
|
||||
<.step>
|
||||
<:title>Step 1. Create a new App Integration in Okta</:title>
|
||||
<:content>
|
||||
<p class="mb-4">
|
||||
Ensure the following scopes are added to the OAuth application:
|
||||
</p>
|
||||
<.code_block
|
||||
id="oauth-scopes"
|
||||
class="w-full text-xs mb-4 whitespace-pre-line rounded"
|
||||
phx-no-format
|
||||
><%= scopes() %></.code_block>
|
||||
|
||||
<p class="mb-4">
|
||||
Ensure the OAuth application has the following redirect URLs whitelisted:
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
<.code_block
|
||||
:for={
|
||||
{type, redirect_url} <- [
|
||||
sign_in: url(~p"/#{@account.id}/sign_in/providers/#{@id}/handle_callback"),
|
||||
connect:
|
||||
url(~p"/#{@account.id}/settings/identity_providers/okta/#{@id}/handle_callback")
|
||||
]
|
||||
}
|
||||
id={"redirect_url-#{type}"}
|
||||
class="w-full mb-4 text-xs whitespace-nowrap rounded"
|
||||
phx-no-format
|
||||
><%= redirect_url %></.code_block>
|
||||
</p>
|
||||
</:content>
|
||||
</.step>
|
||||
|
||||
<.step>
|
||||
<:title>Step 2. Configure Firezone</:title>
|
||||
<:content>
|
||||
<.base_error form={@form} field={:base} />
|
||||
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
|
||||
<div>
|
||||
<.input
|
||||
label="Name"
|
||||
autocomplete="off"
|
||||
field={@form[:name]}
|
||||
placeholder="Name this identity provider"
|
||||
required
|
||||
/>
|
||||
<p class="mt-2 text-xs text-neutral-500">
|
||||
A friendly name for this identity provider. This will be displayed to end-users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.inputs_for :let={adapter_config_form} field={@form[:adapter_config]}>
|
||||
<div>
|
||||
<.input
|
||||
label="Client ID"
|
||||
autocomplete="off"
|
||||
field={adapter_config_form[:client_id]}
|
||||
required
|
||||
/>
|
||||
<p class="mt-2 text-xs text-neutral-500">
|
||||
The Client ID from the previous step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<.input
|
||||
label="Client secret"
|
||||
autocomplete="off"
|
||||
field={adapter_config_form[:client_secret]}
|
||||
required
|
||||
/>
|
||||
<p class="mt-2 text-xs text-neutral-500">
|
||||
The Client secret from the previous step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<.input
|
||||
label="OAuth Authorization Server URI"
|
||||
autocomplete="off"
|
||||
field={adapter_config_form[:oauth_uri]}
|
||||
placeholder="https://<company>.okta.com/.well-known/oauth-authorization-server"
|
||||
required
|
||||
/>
|
||||
<p class="mt-2 text-xs text-neutral-500">
|
||||
The Metadata URI of the Authorization Server for your Okta Application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={visible?(adapter_config_form[:discovery_document_uri].value)}>
|
||||
<.input
|
||||
type="readonly"
|
||||
label="OIDC well-know configuration URL (readonly)"
|
||||
field={adapter_config_form[:discovery_document_uri]}
|
||||
placeholder=".well-known/openid-configuration URL"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-neutral-500">
|
||||
The OIDC Configuration URI. This field is derived from the value in the OAuth Authorization Server URI field.
|
||||
</p>
|
||||
</div>
|
||||
</.inputs_for>
|
||||
</div>
|
||||
|
||||
<.submit_button>
|
||||
Connect Identity Provider
|
||||
</.submit_button>
|
||||
</:content>
|
||||
</.step>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/okta/#{@form.data}/edit"}>
|
||||
Edit <%= # {@form.data.name} %>
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
<:title>
|
||||
Edit Identity Provider <%= @form.data.name %>
|
||||
</:title>
|
||||
<:content>
|
||||
<.provider_form account={@account} id={@form.data.id} form={@form} />
|
||||
</:content>
|
||||
</.section>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("change", %{"provider" => attrs}, socket) do
|
||||
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
|
||||
@@ -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>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}>
|
||||
Create Identity Provider
|
||||
</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/okta/new"}>
|
||||
Okta
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
<:title>
|
||||
Add a new Okta Identity Provider
|
||||
</:title>
|
||||
<:help>
|
||||
For a more detailed guide on setting up Firezone with Okta, please <.link class={link_style()}>refer to our documentation</.link>.
|
||||
</:help>
|
||||
<:content>
|
||||
<.provider_form account={@account} id={@id} form={@form} />
|
||||
</:content>
|
||||
</.section>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("change", %{"provider" => attrs}, socket) do
|
||||
attrs =
|
||||
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
|
||||
@@ -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>
|
||||
|
||||
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/okta/#{@provider}"}>
|
||||
<%= @provider.name %>
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<.section>
|
||||
<:title>
|
||||
Identity Provider <code><%= @provider.name %></code>
|
||||
<span :if={not is_nil(@provider.disabled_at)} class="text-primary-600">(disabled)</span>
|
||||
<span :if={not is_nil(@provider.deleted_at)} class="text-red-600">(deleted)</span>
|
||||
</:title>
|
||||
<:action :if={is_nil(@provider.deleted_at)}>
|
||||
<.edit_button navigate={
|
||||
~p"/#{@account}/settings/identity_providers/okta/#{@provider.id}/edit"
|
||||
}>
|
||||
Edit
|
||||
</.edit_button>
|
||||
</:action>
|
||||
<:action :if={is_nil(@provider.deleted_at)}>
|
||||
<.button
|
||||
:if={is_nil(@provider.disabled_at)}
|
||||
phx-click="disable"
|
||||
style="warning"
|
||||
icon="hero-lock-closed"
|
||||
data-confirm="Are you sure want to disable this provider? Users will no longer be able to sign in with this provider and user / group sync will be paused."
|
||||
>
|
||||
Disable
|
||||
</.button>
|
||||
|
||||
<%= if @provider.adapter_state["status"] != "pending_access_token" do %>
|
||||
<.button
|
||||
:if={not is_nil(@provider.disabled_at)}
|
||||
phx-click="enable"
|
||||
style="warning"
|
||||
icon="hero-lock-open"
|
||||
data-confirm="Are you sure want to enable this provider?"
|
||||
>
|
||||
Enable
|
||||
</.button>
|
||||
<% end %>
|
||||
</:action>
|
||||
<:action :if={is_nil(@provider.deleted_at)}>
|
||||
<.button
|
||||
style="primary"
|
||||
navigate={~p"/#{@account.id}/settings/identity_providers/okta/#{@provider}/redirect"}
|
||||
icon="hero-arrow-path"
|
||||
>
|
||||
Reconnect
|
||||
</.button>
|
||||
</:action>
|
||||
<:content>
|
||||
<.header>
|
||||
<:title>Details</:title>
|
||||
</.header>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
|
||||
<div class="bg-white overflow-hidden">
|
||||
<.vertical_table id="provider">
|
||||
<.vertical_table_row>
|
||||
<:label>Name</:label>
|
||||
<:value><%= @provider.name %></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Status</:label>
|
||||
<:value>
|
||||
<.status provider={@provider} />
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row>
|
||||
<:label>Sync Status</:label>
|
||||
<:value>
|
||||
<.sync_status
|
||||
account={@account}
|
||||
provider={@provider}
|
||||
identities_count_by_provider_id={@identities_count_by_provider_id}
|
||||
groups_count_by_provider_id={@groups_count_by_provider_id}
|
||||
/>
|
||||
<div
|
||||
:if={
|
||||
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
|
||||
(@provider.last_syncs_failed > 3 and not is_nil(@provider.last_sync_error))
|
||||
}
|
||||
class="p-3 mt-2 border-l-4 border-red-500 bg-red-100 rounded-md"
|
||||
>
|
||||
<p class="font-medium text-red-700">
|
||||
IdP provider reported an error during the last sync:
|
||||
</p>
|
||||
<div class="flex items-center mt-1">
|
||||
<span class="text-red-500 font-mono"><%= @provider.last_sync_error %></span>
|
||||
</div>
|
||||
</div>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row>
|
||||
<:label>Client ID</:label>
|
||||
<:value><code><%= @provider.adapter_config["client_id"] %></code></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Callback URLs</:label>
|
||||
<: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 %></.code_block>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Created</:label>
|
||||
<:value>
|
||||
<.created_by account={@account} schema={@provider} />
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</div>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.danger_zone :if={is_nil(@provider.deleted_at)}>
|
||||
<:action>
|
||||
<.delete_button
|
||||
data-confirm="Are you sure want to delete this provider along with all related data?"
|
||||
phx-click="delete"
|
||||
>
|
||||
Delete Identity Provider
|
||||
</.delete_button>
|
||||
</:action>
|
||||
<:content></:content>
|
||||
</.danger_zone>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("delete", _params, socket) do
|
||||
{:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject)
|
||||
|
||||
{:noreply,
|
||||
push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")}
|
||||
end
|
||||
|
||||
def handle_event("enable", _params, socket) do
|
||||
attrs = %{disabled_at: nil}
|
||||
{:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject)
|
||||
|
||||
{:ok, provider} =
|
||||
Auth.fetch_provider_by_id(provider.id, socket.assigns.subject,
|
||||
preload: [created_by_identity: [:actor]]
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, provider: provider)}
|
||||
end
|
||||
|
||||
def handle_event("disable", _params, socket) do
|
||||
attrs = %{disabled_at: DateTime.utc_now()}
|
||||
{:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject)
|
||||
|
||||
{:ok, provider} =
|
||||
Auth.fetch_provider_by_id(provider.id, socket.assigns.subject,
|
||||
preload: [created_by_identity: [:actor]]
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, provider: provider)}
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
14
elixir/apps/web/priv/static/images/okta-logo.svg
Normal file
14
elixir/apps/web/priv/static/images/okta-logo.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 35.975548 36"
|
||||
id="svg2"
|
||||
width="35.975548"
|
||||
height="36"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m 19.792774,0.25 -0.74,9.12 c -0.35,-0.04 -0.7,-0.06 -1.06,-0.06 -0.45,0 -0.89,0.03 -1.32,0.1 l -0.42,-4.42 c -0.01,-0.14 0.1,-0.26 0.24,-0.26 h 0.75 l -0.36,-4.47 c -0.01,-0.14 0.1,-0.26 0.23,-0.26 h 2.45 c 0.14,0 0.25,0.12 0.23,0.26 v 0 z m -6.18,0.45 c -0.04,-0.13 -0.18,-0.21 -0.31,-0.16 l -2.3,0.84 c -0.13,0.05 -0.19,0.2 -0.13,0.32 l 1.87,4.08 -0.71,0.26 c -0.13,0.05 -0.19,0.2 -0.13,0.32 l 1.91,4.01 c 0.69,-0.38 1.44,-0.67 2.23,-0.85 l -2.42,-8.82 z M 7.9727741,3.24 13.262774,10.7 c -0.67,0.44 -1.28,0.96 -1.8,1.56 L 8.2927741,9.14 c -0.1,-0.1 -0.09,-0.26 0.01,-0.35 l 0.58,-0.48 -3.15,-3.19 c -0.1,-0.1 -0.09,-0.26 0.02,-0.35 l 1.87,-1.57 c 0.11,-0.09 0.26,-0.07 0.34,0.04 z m -4.44,4.32 c -0.11,-0.08 -0.27,-0.04 -0.34,0.08 l -1.22,2.12 c -0.07,0.12 -0.02,0.27 0.1,0.33 l 4.06,1.92 -0.38,0.65 c -0.07,0.12 -0.02,0.28 0.11,0.33 l 4.04,1.85 c 0.2899999,-0.75 0.6799999,-1.45 1.1599999,-2.08 z m -2.98999998,5.76 c 0.02,-0.14 0.16,-0.22 0.29,-0.19 l 8.84999998,2.31 c -0.23,0.75 -0.36,1.54 -0.38,2.36 l -4.43,-0.36 c -0.14,-0.01 -0.24,-0.14 -0.21,-0.28 l 0.13,-0.74 L 0.32277412,16 c -0.14,-0.01 -0.23,-0.14 -0.21,-0.28 l 0.42,-2.41 v 0 z m -0.33,5.98 c -0.14,0.01 -0.23,0.14 -0.21,0.28 l 0.43,2.41 c 0.02,0.14 0.16,0.22 0.29,0.19 l 4.33999998,-1.13 0.13,0.74 c 0.02,0.14 0.16,0.22 0.29,0.19 l 4.28,-1.18 c -0.25,-0.74 -0.41,-1.53 -0.45,-2.34 L 0.20277412,19.3 Z m 1.41999998,6.34 c -0.07,-0.12 -0.02,-0.27 0.1,-0.33 l 8.26,-3.92 c 0.3099999,0.74 0.7299999,1.43 1.2299999,2.05 l -3.6199999,2.58 c -0.11,0.08 -0.27,0.05 -0.34,-0.07 l -0.38,-0.66 -3.69,2.55 c -0.11,0.08 -0.27,0.04 -0.34,-0.08 l -1.23,-2.12 z M 11.642774,23.92 5.2127741,30.43 c -0.1,0.1 -0.09,0.26 0.02,0.35 l 1.88,1.57 c 0.11,0.09 0.26,0.07 0.34,-0.04 l 2.5999999,-3.66 0.58,0.49 c 0.11,0.09 0.27,0.07 0.35,-0.05 l 2.52,-3.66 c -0.68,-0.42 -1.31,-0.93 -1.85,-1.51 z m -1.27,10.45 c -0.13,-0.05 -0.19,-0.2 -0.13,-0.32 l 3.81,-8.32 c 0.7,0.36 1.46,0.63 2.25,0.78 l -1.12,4.3 c -0.03,0.13 -0.18,0.21 -0.31,0.16 l -0.71,-0.26 -1.19,4.33 c -0.04,0.13 -0.18,0.21 -0.31,0.16 l -2.3,-0.84 v 0 z m 6.56,-7.75 -0.74,9.12 c -0.01,0.14 0.1,0.26 0.23,0.26 h 2.45 c 0.14,0 0.25,-0.12 0.23,-0.26 l -0.36,-4.47 h 0.75 c 0.14,0 0.25,-0.12 0.24,-0.26 l -0.42,-4.42 c -0.43,0.07 -0.87,0.1 -1.32,0.1 -0.36,0 -0.71,-0.02 -1.06,-0.07 z m 8.82,-24.69 c 0.06,-0.13 0,-0.27 -0.13,-0.32 l -2.3,-0.84 c -0.13,-0.05 -0.27,0.03 -0.31,0.16 l -1.19,4.33 -0.71,-0.26 c -0.13,-0.05 -0.27,0.03 -0.31,0.16 l -1.12,4.3 c 0.8,0.16 1.55,0.43 2.25,0.78 z m 5.02,3.63 -6.43,6.51 c -0.54,-0.58 -1.16,-1.09 -1.85,-1.51 l 2.52,-3.66 c 0.08,-0.11 0.24,-0.14 0.35,-0.05 l 0.58,0.49 2.6,-3.66 c 0.08,-0.11 0.24,-0.13 0.34,-0.04 l 1.88,1.57 c 0.11,0.09 0.11,0.25 0.02,0.35 z m 3.48,5.12 c 0.13,-0.06 0.17,-0.21 0.1,-0.33 l -1.23,-2.12 c -0.07,-0.12 -0.23,-0.15 -0.34,-0.08 l -3.69,2.55 -0.38,-0.65 c -0.07,-0.12 -0.23,-0.16 -0.34,-0.07 l -3.62,2.58 c 0.5,0.62 0.91,1.31 1.23,2.05 l 8.26,-3.92 z m 1.3,3.32 0.42,2.41 c 0.02,0.14 -0.07,0.26 -0.21,0.28 l -9.11,0.85 c -0.04,-0.82 -0.2,-1.6 -0.45,-2.34 l 4.28,-1.18 c 0.13,-0.04 0.27,0.05 0.29,0.19 l 0.13,0.74 4.34,-1.13 c 0.13,-0.03 0.27,0.05 0.29,0.19 v 0 z m -0.41,8.85 c 0.13,0.03 0.27,-0.05 0.29,-0.19 l 0.42,-2.41 c 0.02,-0.14 -0.07,-0.26 -0.21,-0.28 l -4.47,-0.42 0.13,-0.74 c 0.02,-0.14 -0.07,-0.26 -0.21,-0.28 l -4.43,-0.36 c -0.02,0.82 -0.15,1.61 -0.38,2.36 l 8.85,2.31 v 0 z m -2.36,5.5 c -0.07,0.12 -0.23,0.15 -0.34,0.08 l -7.53,-5.2 c 0.48,-0.63 0.87,-1.33 1.16,-2.08 l 4.04,1.85 c 0.13,0.06 0.18,0.21 0.11,0.33 l -0.38,0.65 4.06,1.92 c 0.12,0.06 0.17,0.21 0.1,0.33 z m -10.07,-3.07 5.29,7.46 c 0.08,0.11 0.24,0.13 0.34,0.04 l 1.87,-1.57 c 0.11,-0.09 0.11,-0.25 0.02,-0.35 l -3.15,-3.19 0.58,-0.48 c 0.11,-0.09 0.11,-0.25 0.01,-0.35 l -3.17,-3.12 c -0.53,0.6 -1.13,1.13 -1.8,1.56 v 0 z m -0.05,10.16 c -0.13,0.05 -0.27,-0.03 -0.31,-0.16 l -2.42,-8.82 c 0.79,-0.18 1.54,-0.47 2.23,-0.85 l 1.91,4.01 c 0.06,0.13 0,0.28 -0.13,0.32 l -0.71,0.26 1.87,4.08 c 0.06,0.13 0,0.27 -0.13,0.32 l -2.3,0.84 v 0 z"
|
||||
fill="#191919"
|
||||
fill-rule="evenodd"
|
||||
id="path2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user