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:
Brian Manifold
2024-02-12 21:12:54 -05:00
committed by GitHub
parent 830302af43
commit f18ec6e4d5
41 changed files with 5165 additions and 13 deletions

View File

@@ -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"

View File

@@ -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
}

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
##############################################

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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}>

View File

@@ -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">

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
{

View File

@@ -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
{