mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(portal): Add WorkOS/JumpCloud integration (#5269)
Why: * JumpCloud directory sync was requested from customers. JumpCloud only offers the ability to use it's API with an admin level access token that is tied to a specific user within a given JumpCloud account. This would require Firezone customers to give an access token with much more permissions that needed for our directory sync. To avoid this, we've decide to use WorkOS to provide SCIM support between JumpCloud and WorkOS, which will allow Firezone to then easily and safely retrieve JumpCloud directory info from WorkOS. --------- Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
@@ -266,6 +266,31 @@ Steps:
|
||||
|
||||
When updating the billing plan in stripe, use the [Stripe Testing Docs](https://docs.stripe.com/testing#testing-interactively) for how to add test payment info
|
||||
|
||||
### WorkOS integration
|
||||
|
||||
WorkOS is currently being used for JumpCloud directory sync integration. This allows JumpCloud users to use SCIM on the JumpCloud side, rather than having to give Firezone an admin JumpCloud API token.
|
||||
|
||||
#### Connecting WorkOS in dev mode for manual testing
|
||||
|
||||
If you are not planning to use the JumpCloud provider in your local development setup, then no additional setup is needed.
|
||||
However, if you do need to use the JumpCloud provider locally, you will need to obtain an API Key and Client ID from the [WorkOS Dashboard](https://dashboard.workos.com/api-keys).
|
||||
|
||||
To obtain a WorkOS dashboard login, contact one of the following Firezone team members:
|
||||
|
||||
* @jamilbk
|
||||
* @bmanifold
|
||||
* @AndrewDryga
|
||||
|
||||
Once you are able to login to the WorkOS Dashboard, make sure that you have selected the 'Staging' environment within WorkOS.
|
||||
Navigate to the API Keys page and use the `Create Key` button to obtain credentials.
|
||||
|
||||
After obtaining WorkOS API credentials, you will need to make sure they are set in the environment ENVs when starting your local dev instance of Firezone. As an example:
|
||||
|
||||
```bash
|
||||
cd elixir/
|
||||
WORKOS_API_KEY="..." WORKOS_CLIENT_ID="..." mix phx.server
|
||||
```
|
||||
|
||||
### Acceptance tests
|
||||
|
||||
You can disable headless mode for the browser by adding
|
||||
|
||||
@@ -8,6 +8,7 @@ defmodule Domain.Auth.Adapters do
|
||||
google_workspace: Domain.Auth.Adapters.GoogleWorkspace,
|
||||
microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra,
|
||||
okta: Domain.Auth.Adapters.Okta,
|
||||
jumpcloud: Domain.Auth.Adapters.JumpCloud,
|
||||
userpass: Domain.Auth.Adapters.UserPass
|
||||
}
|
||||
|
||||
|
||||
83
elixir/apps/domain/lib/domain/auth/adapters/jumpcloud.ex
Normal file
83
elixir/apps/domain/lib/domain/auth/adapters/jumpcloud.ex
Normal file
@@ -0,0 +1,83 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud do
|
||||
use Supervisor
|
||||
alias Domain.Actors
|
||||
alias Domain.Auth.{Provider, Adapter}
|
||||
alias Domain.Auth.Adapters.OpenIDConnect
|
||||
alias Domain.Auth.Adapters.JumpCloud
|
||||
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 = [
|
||||
JumpCloud.APIClient,
|
||||
# Background Jobs
|
||||
JumpCloud.Jobs.SyncDirectory
|
||||
]
|
||||
|
||||
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.Repo.Changeset.trim_change(:provider_identifier)
|
||||
|> Domain.Repo.Changeset.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.Repo.Changeset.cast_polymorphic_embed(:adapter_config,
|
||||
required: true,
|
||||
with: fn current_attrs, attrs ->
|
||||
Ecto.embedded_load(JumpCloud.Settings, current_attrs, :json)
|
||||
|> JumpCloud.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
|
||||
@@ -0,0 +1,89 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud.APIClient do
|
||||
use Supervisor
|
||||
|
||||
def start_link(_init_arg) do
|
||||
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = []
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def list_users(nil) do
|
||||
{:error, "No directory to fetch users from"}
|
||||
end
|
||||
|
||||
def list_users(directory) do
|
||||
list_all_users(directory.id, :start)
|
||||
end
|
||||
|
||||
defp list_all_users(directory_id, after_record, acc \\ []) do
|
||||
list_users_params =
|
||||
%{directory: directory_id}
|
||||
|> add_after_param(after_record)
|
||||
|> Map.put(:limit, 100)
|
||||
|
||||
client = fetch_workos_client()
|
||||
|
||||
case WorkOS.DirectorySync.list_users(client, list_users_params) do
|
||||
{:ok, %WorkOS.List{data: users, list_metadata: %{"after" => nil}}} ->
|
||||
{:ok, List.flatten(Enum.reverse([users | acc]))}
|
||||
|
||||
{:ok, %WorkOS.List{data: users, list_metadata: %{"after" => last_record}}} ->
|
||||
list_all_users(directory_id, last_record, [users | acc])
|
||||
|
||||
{:error, %WorkOS.Error{} = error} ->
|
||||
{:error, error}
|
||||
|
||||
{:error, msg} ->
|
||||
{:error, msg}
|
||||
end
|
||||
end
|
||||
|
||||
def list_groups(nil) do
|
||||
{:error, "No directory to fetch groups from"}
|
||||
end
|
||||
|
||||
def list_groups(directory) do
|
||||
list_all_groups(directory.id, :start)
|
||||
end
|
||||
|
||||
defp list_all_groups(directory_id, after_record, acc \\ []) do
|
||||
list_groups_params =
|
||||
%{directory: directory_id}
|
||||
|> add_after_param(after_record)
|
||||
|> Map.put(:limit, 100)
|
||||
|
||||
client = fetch_workos_client()
|
||||
|
||||
case WorkOS.DirectorySync.list_groups(client, list_groups_params) do
|
||||
{:ok, %WorkOS.List{data: groups, list_metadata: %{"after" => nil}}} ->
|
||||
{:ok, List.flatten(Enum.reverse([groups | acc]))}
|
||||
|
||||
{:ok, %WorkOS.List{data: groups, list_metadata: %{"after" => last_record}}} ->
|
||||
list_all_groups(directory_id, last_record, [groups | acc])
|
||||
|
||||
{:error, %WorkOS.Error{} = error} ->
|
||||
{:error, error}
|
||||
|
||||
{:error, msg} ->
|
||||
{:error, msg}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_after_param(params, value) do
|
||||
case value do
|
||||
:start -> params
|
||||
nil -> params
|
||||
_ -> Map.put(params, :after, value)
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_workos_client do
|
||||
Domain.Config.fetch_env!(:workos, WorkOS.Client)
|
||||
|> WorkOS.Client.new()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud.Jobs.SyncDirectory do
|
||||
use Domain.Jobs.Job,
|
||||
otp_app: :domain,
|
||||
every: :timer.minutes(5),
|
||||
executor: Domain.Jobs.Executors.Concurrent
|
||||
|
||||
alias Domain.Auth.Adapter.OpenIDConnect.DirectorySync
|
||||
alias Domain.Auth.Adapters.JumpCloud
|
||||
require Logger
|
||||
require OpenTelemetry.Tracer
|
||||
|
||||
@task_supervisor __MODULE__.TaskSupervisor
|
||||
|
||||
@impl true
|
||||
def state(_config) do
|
||||
{:ok, pid} = Task.Supervisor.start_link(name: @task_supervisor)
|
||||
{:ok, %{task_supervisor: pid}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def execute(%{task_supervisor: pid}) do
|
||||
DirectorySync.sync_providers(__MODULE__, :jumpcloud, pid)
|
||||
end
|
||||
|
||||
def gather_provider_data(provider, task_supervisor_pid) do
|
||||
with {:ok, %WorkOS.DirectorySync.Directory{} = directory} <-
|
||||
Domain.Auth.DirectorySync.WorkOS.fetch_directory(provider) do
|
||||
async_results =
|
||||
DirectorySync.run_async_requests(task_supervisor_pid,
|
||||
users: fn ->
|
||||
JumpCloud.APIClient.list_users(directory)
|
||||
end,
|
||||
groups: fn ->
|
||||
JumpCloud.APIClient.list_groups(directory)
|
||||
end
|
||||
)
|
||||
|
||||
with {:ok, %{users: users, groups: groups}} <- async_results,
|
||||
membership_tuples <- membership_tuples(users) do
|
||||
identities_attrs = map_identity_attrs(users)
|
||||
actor_groups_attrs = map_group_attrs(groups)
|
||||
{:ok, {identities_attrs, actor_groups_attrs, membership_tuples}}
|
||||
else
|
||||
{:error, %WorkOS.Error{} = error} ->
|
||||
{:error, "Error connecting to WorkOS", error.message}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, nil, inspect(reason)}
|
||||
|
||||
_ ->
|
||||
{:error, nil, "An unknown error occurred"}
|
||||
end
|
||||
else
|
||||
{:ok, nil} ->
|
||||
{:error, nil, "No WorkOS Directory has been created"}
|
||||
|
||||
{:error, %WorkOS.Error{} = error} ->
|
||||
{:error, "Error connecting to WorkOS", error.message}
|
||||
|
||||
{:error, msg} ->
|
||||
{:error, msg, msg}
|
||||
|
||||
_ ->
|
||||
{:error, nil, "An unknown error occurred"}
|
||||
end
|
||||
end
|
||||
|
||||
defp membership_tuples(users) do
|
||||
Enum.flat_map(users, fn user ->
|
||||
Enum.map(user.groups, &{"G:" <> &1.id, user.idp_id})
|
||||
end)
|
||||
end
|
||||
|
||||
# Map identity attributes from JumpCloud to Domain
|
||||
defp map_identity_attrs(users) do
|
||||
Enum.map(users, fn user ->
|
||||
%{
|
||||
"provider_identifier" => user.idp_id,
|
||||
"provider_state" => %{
|
||||
"userinfo" => %{
|
||||
"email" => user.username
|
||||
}
|
||||
},
|
||||
"actor" => %{
|
||||
"type" => :account_user,
|
||||
"name" => "#{user.first_name} #{user.last_name}"
|
||||
}
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Map group attributes from WorkOS to Domain
|
||||
defp map_group_attrs(groups) do
|
||||
Enum.map(groups, fn group ->
|
||||
%{
|
||||
"name" => "Group:" <> group.name,
|
||||
"provider_identifier" => "G:" <> group.id
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud.Settings do
|
||||
use Domain, :schema
|
||||
|
||||
@scope ~w[
|
||||
openid email profile
|
||||
]
|
||||
|
||||
@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 :workos_org, :map
|
||||
end
|
||||
|
||||
def scope, do: @scope
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud.Settings.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth.Adapters.JumpCloud.Settings
|
||||
alias Domain.Auth.Adapters.OpenIDConnect
|
||||
|
||||
@fields ~w[scope
|
||||
response_type
|
||||
client_id client_secret
|
||||
discovery_document_uri
|
||||
workos_org]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
|
||||
16
elixir/apps/domain/lib/domain/auth/directory_sync.ex
Normal file
16
elixir/apps/domain/lib/domain/auth/directory_sync.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Domain.Auth.DirectorySync do
|
||||
use Supervisor
|
||||
alias Domain.Auth.DirectorySync.WorkOS
|
||||
|
||||
def start_link(opts) do
|
||||
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(_opts) do
|
||||
children = [
|
||||
WorkOS
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
85
elixir/apps/domain/lib/domain/auth/directory_sync/workos.ex
Normal file
85
elixir/apps/domain/lib/domain/auth/directory_sync/workos.ex
Normal file
@@ -0,0 +1,85 @@
|
||||
defmodule Domain.Auth.DirectorySync.WorkOS do
|
||||
def fetch_directory(%Domain.Auth.Provider{} = provider) do
|
||||
case provider.adapter_config do
|
||||
%{"workos_org" => %{"id" => org_id}} ->
|
||||
fetch_directory(org_id)
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_directory(workos_org_id) do
|
||||
client = fetch_workos_client()
|
||||
|
||||
case WorkOS.DirectorySync.list_directories(client, %{organization_id: workos_org_id}) do
|
||||
{:ok, %WorkOS.List{data: [directory]}} ->
|
||||
{:ok, directory}
|
||||
|
||||
{:ok, %WorkOS.List{data: []}} ->
|
||||
{:ok, nil}
|
||||
|
||||
{:error, %WorkOS.Error{message: _msg} = error} ->
|
||||
{:error, error}
|
||||
|
||||
_ ->
|
||||
{:error, "Something went wrong fetching directory"}
|
||||
end
|
||||
end
|
||||
|
||||
def create_organization(provider, subject) do
|
||||
client = fetch_workos_client()
|
||||
|
||||
with {:ok, workos_org} <-
|
||||
WorkOS.Organizations.create_organization(client, %{name: provider.id}),
|
||||
{:ok, _} <-
|
||||
Domain.Auth.update_provider(
|
||||
provider,
|
||||
%{adapter_config: %{"workos_org" => Map.from_struct(workos_org)}},
|
||||
subject
|
||||
) do
|
||||
{:ok, workos_org}
|
||||
else
|
||||
{:error, %WorkOS.Error{message: msg}} ->
|
||||
{:error, msg}
|
||||
|
||||
_ ->
|
||||
{:error, "Something went wrong creating organization"}
|
||||
end
|
||||
end
|
||||
|
||||
def create_portal_link(provider, return_url, subject) do
|
||||
with {:ok, workos_org} <- fetch_or_create_workos_org(provider, subject),
|
||||
{:ok, workos_portal_link} <-
|
||||
WorkOS.Portal.generate_link(%{
|
||||
organization: workos_org.id,
|
||||
intent: "dsync",
|
||||
success_url: return_url
|
||||
}) do
|
||||
{:ok, workos_portal_link}
|
||||
else
|
||||
{:error, %WorkOS.Error{message: msg}} ->
|
||||
{:error, msg}
|
||||
|
||||
_ ->
|
||||
{:error, "Something went wrong creating portal link"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_or_create_workos_org(provider, subject) do
|
||||
client = fetch_workos_client()
|
||||
|
||||
case provider.adapter_config do
|
||||
%{"workos_org" => %{"id" => workos_org_id}} ->
|
||||
WorkOS.Organizations.get_organization(client, workos_org_id)
|
||||
|
||||
%{} ->
|
||||
__MODULE__.create_organization(provider, subject)
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_workos_client do
|
||||
Domain.Config.fetch_env!(:workos, WorkOS.Client)
|
||||
|> WorkOS.Client.new()
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ defmodule Domain.Auth.Provider do
|
||||
field :name, :string
|
||||
|
||||
field :adapter, Ecto.Enum,
|
||||
values: ~w[email openid_connect google_workspace microsoft_entra okta userpass]a
|
||||
values: ~w[email openid_connect google_workspace microsoft_entra okta jumpcloud userpass]a
|
||||
|
||||
field :provisioner, Ecto.Enum, values: ~w[manual just_in_time custom]a
|
||||
field :adapter_config, :map, redact: true
|
||||
|
||||
@@ -440,10 +440,11 @@ defmodule Domain.Config.Definitions do
|
||||
google_workspace
|
||||
microsoft_entra
|
||||
okta
|
||||
jumpcloud
|
||||
userpass
|
||||
token
|
||||
]a)}},
|
||||
default: ~w[email openid_connect google_workspace microsoft_entra okta token]a
|
||||
default: ~w[email openid_connect google_workspace microsoft_entra okta jumpcloud token]a
|
||||
)
|
||||
|
||||
##############################################
|
||||
@@ -599,6 +600,13 @@ defmodule Domain.Config.Definitions do
|
||||
defconfig(:stripe_webhook_signing_secret, :string, sensitive: true, default: nil)
|
||||
defconfig(:stripe_default_price_id, :string, default: nil)
|
||||
|
||||
##############################################
|
||||
## WorkOS flags
|
||||
##############################################
|
||||
|
||||
defconfig(:workos_api_key, :string, sensitive: true, default: "")
|
||||
defconfig(:workos_client_id, :string, default: "")
|
||||
|
||||
##############################################
|
||||
## Local development and Staging Helpers
|
||||
##############################################
|
||||
|
||||
@@ -58,6 +58,7 @@ defmodule Domain.MixProject do
|
||||
{:openid_connect,
|
||||
github: "firezone/openid_connect", ref: "dee689382699fce7a6ca70084ccbc8bc351d3246"},
|
||||
{:argon2_elixir, "~> 4.0"},
|
||||
{:workos, git: "https://github.com/firezone/workos-elixir.git", branch: "main"},
|
||||
|
||||
# Erlang Clustering
|
||||
{:libcluster, "~> 3.3"},
|
||||
|
||||
303
elixir/apps/domain/test/domain/auth/adapters/jumpcloud.exs
Normal file
303
elixir/apps/domain/test/domain/auth/adapters/jumpcloud.exs
Normal file
@@ -0,0 +1,303 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloudTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Auth.Adapters.JumpCloud
|
||||
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: :jumpcloud, 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"],
|
||||
workos_org: ["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: :jumpcloud,
|
||||
adapter_config: %{
|
||||
client_id: "client_id",
|
||||
client_secret: "client_secret",
|
||||
discovery_document_uri: discovery_document_url,
|
||||
workos_org: %{}
|
||||
}
|
||||
)
|
||||
|
||||
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"], " "),
|
||||
"response_type" => "code",
|
||||
"client_id" => "client_id",
|
||||
"client_secret" => "client_secret",
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"workos_org" => %{}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_deprovisioned/1" do
|
||||
test "does nothing for a provider" do
|
||||
{provider, _bypass} = Fixtures.Auth.start_and_create_jumpcloud_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"
|
||||
|
||||
{token, claims} =
|
||||
Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity, %{
|
||||
"email" => email
|
||||
})
|
||||
|
||||
Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token})
|
||||
|
||||
Mocks.OpenIDConnect.expect_userinfo(bypass, %{
|
||||
"email" => email,
|
||||
"sub" => identity.provider_identifier
|
||||
})
|
||||
|
||||
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" => identity.provider_identifier
|
||||
}
|
||||
}
|
||||
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, %{
|
||||
"email" => "foobar@example.com",
|
||||
"sub" => identity.provider_identifier
|
||||
})
|
||||
|
||||
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, %{
|
||||
"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, %{
|
||||
"email" => "foobar@example.com",
|
||||
"sub" => identity.provider_identifier
|
||||
})
|
||||
|
||||
Mocks.OpenIDConnect.expect_refresh_token(bypass, %{
|
||||
"token_type" => "Bearer",
|
||||
"id_token" => token,
|
||||
"access_token" => "MY_ACCESS_TOKEN",
|
||||
"refresh_token" => "MY_REFRESH_TOKEN",
|
||||
"expires_in" => 3600
|
||||
})
|
||||
|
||||
Mocks.OpenIDConnect.expect_userinfo(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is down", %{
|
||||
provider: provider,
|
||||
bypass: bypass
|
||||
} do
|
||||
Bypass.down(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :internal_error}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud.APIClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
alias Domain.Fixtures
|
||||
alias Domain.Mocks.WorkOSDirectory
|
||||
import Domain.Auth.Adapters.JumpCloud.APIClient
|
||||
|
||||
describe "list_users/1" do
|
||||
test "returns list of users" do
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
|
||||
WorkOSDirectory.mock_list_users_endpoint(bypass)
|
||||
|
||||
directory = Fixtures.WorkOS.create_directory()
|
||||
|
||||
assert {:ok, users} = list_users(directory)
|
||||
|
||||
assert length(users) == 1
|
||||
|
||||
for user <- users do
|
||||
assert Map.has_key?(user, :id)
|
||||
assert Map.has_key?(user, :idp_id)
|
||||
|
||||
# Profile fields
|
||||
assert Map.has_key?(user, :groups)
|
||||
assert Map.has_key?(user, :first_name)
|
||||
assert Map.has_key?(user, :last_name)
|
||||
assert Map.has_key?(user, :state)
|
||||
assert Map.has_key?(user, :username)
|
||||
assert Map.has_key?(user, :emails)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns error when WorkOS API is down" do
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
Bypass.down(bypass)
|
||||
|
||||
directory = Fixtures.WorkOS.create_directory()
|
||||
assert list_users(directory) == {:error, :client_error}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_groups/1" do
|
||||
test "returns list of groups" do
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
|
||||
WorkOSDirectory.mock_list_groups_endpoint(bypass)
|
||||
|
||||
# Mocks.WorkOSDirectory.list_groups()
|
||||
|
||||
directory = Fixtures.WorkOS.create_directory()
|
||||
|
||||
assert {:ok, groups} = list_groups(directory)
|
||||
|
||||
assert length(groups) == 1
|
||||
|
||||
for group <- groups do
|
||||
assert Map.has_key?(group, :id)
|
||||
assert Map.has_key?(group, :idp_id)
|
||||
assert Map.has_key?(group, :name)
|
||||
assert Map.has_key?(group, :organization_id)
|
||||
assert Map.has_key?(group, :directory_id)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns error when WorkOS API is down" do
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
Bypass.down(bypass)
|
||||
|
||||
directory = Fixtures.WorkOS.create_directory()
|
||||
|
||||
assert list_groups(directory) == {:error, :client_error}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,565 @@
|
||||
defmodule Domain.Auth.Adapters.JumpCloud.Jobs.SyncDirectoryTest do
|
||||
use Domain.DataCase, async: true
|
||||
alias Domain.{Auth, Actors}
|
||||
alias Domain.Mocks.WorkOSDirectory
|
||||
import Domain.Auth.Adapters.JumpCloud.Jobs.SyncDirectory
|
||||
|
||||
describe "execute/1" do
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
{provider, bypass} =
|
||||
Fixtures.Auth.start_and_create_jumpcloud_provider(account: account)
|
||||
|
||||
%{
|
||||
bypass: bypass,
|
||||
account: account,
|
||||
provider: provider
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when IdP sync is not enabled", %{account: account, provider: provider} do
|
||||
{:ok, _account} = Domain.Accounts.update_account(account, %{features: %{idp_sync: false}})
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :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 ==
|
||||
"IdP sync is not enabled in your subscription plan"
|
||||
end
|
||||
|
||||
test "syncs IdP data", %{provider: provider} do
|
||||
bypass = Bypass.open()
|
||||
|
||||
groups = [
|
||||
%{
|
||||
"id" => "GROUP_ALL_ID",
|
||||
"object" => "directory_group",
|
||||
"idp_id" => "all",
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"name" => "All",
|
||||
"created_at" => "2021-10-27 15:21:50.640958",
|
||||
"updated_at" => "2021-12-13 12:15:45.531847",
|
||||
"raw_attributes" => %{}
|
||||
},
|
||||
%{
|
||||
"id" => "GROUP_ENGINEERING_ID",
|
||||
"object" => "directory_group",
|
||||
"idp_id" => "engineering",
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"name" => "Engineering",
|
||||
"created_at" => "2021-10-27 15:21:50.640958",
|
||||
"updated_at" => "2021-12-13 12:15:45.531847",
|
||||
"raw_attributes" => %{}
|
||||
}
|
||||
]
|
||||
|
||||
users = [
|
||||
%{
|
||||
"id" => "workos_user_jdoe_id",
|
||||
"object" => "directory_user",
|
||||
"custom_attributes" => %{},
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"emails" => [
|
||||
%{
|
||||
"primary" => true,
|
||||
"type" => "type",
|
||||
"value" => "jdoe@example.local"
|
||||
}
|
||||
],
|
||||
"groups" => groups,
|
||||
"idp_id" => "USER_JDOE_ID",
|
||||
"first_name" => "John",
|
||||
"last_name" => "Doe",
|
||||
"job_title" => "Software Eng",
|
||||
"raw_attributes" => %{},
|
||||
"state" => "active",
|
||||
"username" => "jdoe@example.local",
|
||||
"created_at" => "2023-07-17T20:07:20.055Z",
|
||||
"updated_at" => "2023-07-17T20:07:20.055Z"
|
||||
},
|
||||
%{
|
||||
"id" => "workos_user_jsmith_id",
|
||||
"object" => "directory_user",
|
||||
"custom_attributes" => %{},
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"emails" => [
|
||||
%{
|
||||
"primary" => true,
|
||||
"type" => "type",
|
||||
"value" => "jsmith@example.local"
|
||||
}
|
||||
],
|
||||
"groups" => groups,
|
||||
"idp_id" => "USER_JSMITH_ID",
|
||||
"first_name" => "Jane",
|
||||
"last_name" => "Smith",
|
||||
"job_title" => "Software Eng",
|
||||
"raw_attributes" => %{},
|
||||
"state" => "active",
|
||||
"username" => "jsmith@example.local",
|
||||
"created_at" => "2023-07-17T20:07:20.055Z",
|
||||
"updated_at" => "2023-07-17T20:07:20.055Z"
|
||||
}
|
||||
]
|
||||
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
WorkOSDirectory.mock_list_users_endpoint(bypass, users)
|
||||
WorkOSDirectory.mock_list_groups_endpoint(bypass, groups)
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :ok
|
||||
|
||||
groups = Actors.Group |> Repo.all()
|
||||
assert length(groups) == 2
|
||||
|
||||
for group <- groups do
|
||||
assert group.provider_identifier in ["G:GROUP_ALL_ID", "G:GROUP_ENGINEERING_ID"]
|
||||
assert group.name in ["Group:All", "Group:Engineering"]
|
||||
|
||||
assert group.inserted_at
|
||||
assert group.updated_at
|
||||
|
||||
assert group.created_by == :provider
|
||||
assert group.provider_id == provider.id
|
||||
end
|
||||
|
||||
identities = Auth.Identity |> Repo.all() |> Repo.preload(:actor)
|
||||
assert length(identities) == 2
|
||||
|
||||
for identity <- identities do
|
||||
assert identity.inserted_at
|
||||
assert identity.created_by == :provider
|
||||
assert identity.provider_id == provider.id
|
||||
assert identity.provider_identifier in ["USER_JDOE_ID", "USER_JSMITH_ID"]
|
||||
|
||||
assert identity.provider_state in [
|
||||
%{"userinfo" => %{"email" => "jdoe@example.local"}},
|
||||
%{"userinfo" => %{"email" => "jsmith@example.local"}}
|
||||
]
|
||||
|
||||
assert identity.actor.name in ["John Doe", "Jane Smith"]
|
||||
assert identity.actor.last_synced_at
|
||||
end
|
||||
|
||||
memberships = Actors.Membership |> Repo.all()
|
||||
assert length(memberships) == 4
|
||||
|
||||
updated_provider = Repo.get!(Domain.Auth.Provider, provider.id)
|
||||
assert updated_provider.last_synced_at != provider.last_synced_at
|
||||
end
|
||||
|
||||
test "does not crash on endpoint errors" do
|
||||
bypass = Bypass.open()
|
||||
Bypass.down(bypass)
|
||||
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :ok
|
||||
|
||||
assert Repo.aggregate(Actors.Group, :count) == 0
|
||||
end
|
||||
|
||||
test "updates existing identities and actors", %{account: account, provider: provider} do
|
||||
bypass = Bypass.open()
|
||||
|
||||
users = [
|
||||
%{
|
||||
"id" => "workos_user_jdoe_id",
|
||||
"object" => "directory_user",
|
||||
"custom_attributes" => %{},
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"emails" => [
|
||||
%{
|
||||
"primary" => true,
|
||||
"type" => "type",
|
||||
"value" => "jdoe@example.local"
|
||||
}
|
||||
],
|
||||
"groups" => [],
|
||||
"idp_id" => "USER_JDOE_ID",
|
||||
"first_name" => "John",
|
||||
"last_name" => "Doe",
|
||||
"job_title" => "Software Eng",
|
||||
"raw_attributes" => %{},
|
||||
"state" => "active",
|
||||
"username" => "jdoe@example.local",
|
||||
"created_at" => "2023-07-17T20:07:20.055Z",
|
||||
"updated_at" => "2023-07-17T20:07:20.055Z"
|
||||
}
|
||||
]
|
||||
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
identity =
|
||||
Fixtures.Auth.create_identity(
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: actor,
|
||||
provider_identifier: "USER_JDOE_ID"
|
||||
)
|
||||
|
||||
assert original_identity =
|
||||
Repo.get(Domain.Auth.Identity, identity.id)
|
||||
|> Repo.preload(:actor)
|
||||
|
||||
refute original_identity.actor.name == "John Doe"
|
||||
|
||||
refute original_identity.provider_state == %{
|
||||
"userinfo" => %{"email" => "jdoe@example.local"}
|
||||
}
|
||||
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
WorkOSDirectory.mock_list_users_endpoint(bypass, users)
|
||||
WorkOSDirectory.mock_list_groups_endpoint(bypass)
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :ok
|
||||
|
||||
assert updated_identity =
|
||||
Repo.get(Domain.Auth.Identity, identity.id)
|
||||
|> Repo.preload(:actor)
|
||||
|
||||
assert updated_identity.provider_state == %{
|
||||
"userinfo" => %{"email" => "jdoe@example.local"}
|
||||
}
|
||||
|
||||
assert updated_identity.actor.name == "John Doe"
|
||||
assert updated_identity.actor.last_synced_at
|
||||
end
|
||||
|
||||
test "updates existing groups and memberships", %{account: account, provider: provider} do
|
||||
bypass = Bypass.open()
|
||||
|
||||
group_all = %{
|
||||
"id" => "GROUP_ALL_ID",
|
||||
"object" => "directory_group",
|
||||
"idp_id" => "all",
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"name" => "All",
|
||||
"created_at" => "2021-10-27 15:21:50.640958",
|
||||
"updated_at" => "2021-12-13 12:15:45.531847",
|
||||
"raw_attributes" => %{}
|
||||
}
|
||||
|
||||
group_engineering = %{
|
||||
"id" => "GROUP_ENGINEERING_ID",
|
||||
"object" => "directory_group",
|
||||
"idp_id" => "engineering",
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"name" => "Engineering",
|
||||
"created_at" => "2021-10-27 15:21:50.640958",
|
||||
"updated_at" => "2021-12-13 12:15:45.531847",
|
||||
"raw_attributes" => %{}
|
||||
}
|
||||
|
||||
users = [
|
||||
%{
|
||||
"id" => "workos_user_jdoe_id",
|
||||
"object" => "directory_user",
|
||||
"custom_attributes" => %{},
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"emails" => [
|
||||
%{
|
||||
"primary" => true,
|
||||
"type" => "type",
|
||||
"value" => "jdoe@example.local"
|
||||
}
|
||||
],
|
||||
"groups" => [group_all, group_engineering],
|
||||
"idp_id" => "USER_JDOE_ID",
|
||||
"first_name" => "John",
|
||||
"last_name" => "Doe",
|
||||
"job_title" => "Software Eng",
|
||||
"raw_attributes" => %{},
|
||||
"state" => "active",
|
||||
"username" => "jdoe@example.local",
|
||||
"created_at" => "2023-07-17T20:07:20.055Z",
|
||||
"updated_at" => "2023-07-17T20:07:20.055Z"
|
||||
},
|
||||
%{
|
||||
"id" => "workos_user_jsmith_id",
|
||||
"object" => "directory_user",
|
||||
"custom_attributes" => %{},
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"emails" => [
|
||||
%{
|
||||
"primary" => true,
|
||||
"type" => "type",
|
||||
"value" => "jsmith@example.local"
|
||||
}
|
||||
],
|
||||
"groups" => [group_all],
|
||||
"idp_id" => "USER_JSMITH_ID",
|
||||
"first_name" => "Jane",
|
||||
"last_name" => "Smith",
|
||||
"job_title" => "Software Eng",
|
||||
"raw_attributes" => %{},
|
||||
"state" => "active",
|
||||
"username" => "jsmith@example.local",
|
||||
"created_at" => "2023-07-17T20:07:20.055Z",
|
||||
"updated_at" => "2023-07-17T20:07:20.055Z"
|
||||
}
|
||||
]
|
||||
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
Fixtures.Auth.create_identity(
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: actor,
|
||||
provider_identifier: "USER_JDOE_ID"
|
||||
)
|
||||
|
||||
other_actor = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
Fixtures.Auth.create_identity(
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: other_actor,
|
||||
provider_identifier: "USER_JSMITH_ID"
|
||||
)
|
||||
|
||||
deleted_identity =
|
||||
Fixtures.Auth.create_identity(
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: other_actor,
|
||||
provider_identifier: "USER_JSMITH_ID2"
|
||||
)
|
||||
|
||||
deleted_identity_token =
|
||||
Fixtures.Tokens.create_token(
|
||||
account: account,
|
||||
actor: other_actor,
|
||||
identity: deleted_identity
|
||||
)
|
||||
|
||||
deleted_identity_client =
|
||||
Fixtures.Clients.create_client(
|
||||
account: account,
|
||||
actor: other_actor,
|
||||
identity: deleted_identity
|
||||
)
|
||||
|
||||
deleted_identity_flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
client: deleted_identity_client,
|
||||
token_id: deleted_identity_token.id
|
||||
)
|
||||
|
||||
group =
|
||||
Fixtures.Actors.create_group(
|
||||
account: account,
|
||||
provider: provider,
|
||||
provider_identifier: "G:GROUP_ALL_ID"
|
||||
)
|
||||
|
||||
deleted_group =
|
||||
Fixtures.Actors.create_group(
|
||||
account: account,
|
||||
provider: provider,
|
||||
provider_identifier: "G:DELETED_GROUP_ID!"
|
||||
)
|
||||
|
||||
policy = Fixtures.Policies.create_policy(account: account, actor_group: group)
|
||||
|
||||
deleted_policy =
|
||||
Fixtures.Policies.create_policy(account: account, actor_group: deleted_group)
|
||||
|
||||
deleted_group_flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
actor_group: deleted_group,
|
||||
resource_id: deleted_policy.resource_id,
|
||||
policy: deleted_policy
|
||||
)
|
||||
|
||||
Fixtures.Actors.create_membership(account: account, actor: actor)
|
||||
Fixtures.Actors.create_membership(account: account, actor: actor, group: group)
|
||||
deleted_membership = Fixtures.Actors.create_membership(account: account, group: group)
|
||||
Fixtures.Actors.create_membership(account: account, actor: actor, group: deleted_group)
|
||||
|
||||
:ok = Domain.Actors.subscribe_to_membership_updates_for_actor(actor)
|
||||
:ok = Domain.Actors.subscribe_to_membership_updates_for_actor(other_actor)
|
||||
:ok = Domain.Actors.subscribe_to_membership_updates_for_actor(deleted_membership.actor_id)
|
||||
:ok = Domain.Policies.subscribe_to_events_for_actor(actor)
|
||||
:ok = Domain.Policies.subscribe_to_events_for_actor(other_actor)
|
||||
:ok = Domain.Policies.subscribe_to_events_for_actor_group(deleted_group)
|
||||
:ok = Domain.Flows.subscribe_to_flow_expiration_events(deleted_group_flow)
|
||||
:ok = Domain.Flows.subscribe_to_flow_expiration_events(deleted_identity_flow)
|
||||
:ok = Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{deleted_identity_token.id}")
|
||||
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
WorkOSDirectory.mock_list_users_endpoint(bypass, users)
|
||||
WorkOSDirectory.mock_list_groups_endpoint(bypass, [group_all, group_engineering])
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :ok
|
||||
|
||||
assert updated_group = Repo.get(Domain.Actors.Group, group.id)
|
||||
assert updated_group.name == "Group:All"
|
||||
|
||||
assert created_group =
|
||||
Repo.get_by(Domain.Actors.Group, provider_identifier: "G:GROUP_ENGINEERING_ID")
|
||||
|
||||
assert created_group.name == "Group:Engineering"
|
||||
|
||||
assert memberships = Repo.all(Domain.Actors.Membership.Query.all())
|
||||
assert length(memberships) == 4
|
||||
|
||||
assert memberships = Repo.all(Domain.Actors.Membership.Query.with_joined_groups())
|
||||
assert length(memberships) == 4
|
||||
|
||||
membership_group_ids = Enum.map(memberships, & &1.group_id)
|
||||
assert group.id in membership_group_ids
|
||||
assert deleted_group.id not in membership_group_ids
|
||||
|
||||
# Deletes membership for a deleted group
|
||||
actor_id = actor.id
|
||||
group_id = deleted_group.id
|
||||
assert_receive {:delete_membership, ^actor_id, ^group_id}
|
||||
|
||||
# Created membership for a new group
|
||||
actor_id = actor.id
|
||||
group_id = created_group.id
|
||||
assert_receive {:create_membership, ^actor_id, ^group_id}
|
||||
|
||||
# Created membership for a member of existing group
|
||||
other_actor_id = other_actor.id
|
||||
group_id = group.id
|
||||
assert_receive {:create_membership, ^other_actor_id, ^group_id}
|
||||
|
||||
# Broadcasts allow_access for it
|
||||
policy_id = policy.id
|
||||
group_id = group.id
|
||||
resource_id = policy.resource_id
|
||||
assert_receive {:allow_access, ^policy_id, ^group_id, ^resource_id}
|
||||
|
||||
# Deletes membership that is not found on IdP end
|
||||
actor_id = deleted_membership.actor_id
|
||||
group_id = deleted_membership.group_id
|
||||
assert_receive {:delete_membership, ^actor_id, ^group_id}
|
||||
|
||||
# Signs out users which identity has been deleted
|
||||
topic = "sessions:#{deleted_identity_token.id}"
|
||||
assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "disconnect", payload: nil}
|
||||
|
||||
# Deleted group deletes all policies and broadcasts reject access events for them
|
||||
policy_id = deleted_policy.id
|
||||
group_id = deleted_group.id
|
||||
resource_id = deleted_policy.resource_id
|
||||
assert_receive {:reject_access, ^policy_id, ^group_id, ^resource_id}
|
||||
|
||||
# Deleted policies expire all flows authorized by them
|
||||
flow_id = deleted_group_flow.id
|
||||
assert_receive {:expire_flow, ^flow_id, _client_id, ^resource_id}
|
||||
|
||||
# Expires flows for signed out user
|
||||
flow_id = deleted_identity_flow.id
|
||||
assert_receive {:expire_flow, ^flow_id, _client_id, _resource_id}
|
||||
|
||||
# Should not do anything else
|
||||
refute_receive {:create_membership, _actor_id, _group_id}
|
||||
refute_received {:remove_membership, _actor_id, _group_id}
|
||||
refute_received {:allow_access, _policy_id, _group_id, _resource_id}
|
||||
refute_received {:reject_access, _policy_id, _group_id, _resource_id}
|
||||
refute_received {:expire_flow, _flow_id, _client_id, _resource_id}
|
||||
end
|
||||
|
||||
test "stops the sync retires on 401 error from WorkOS", %{provider: provider} do
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
|
||||
error_message = "Error connecting to WorkOS"
|
||||
|
||||
response = %{"message" => "Unauthorized"}
|
||||
|
||||
for path <- [
|
||||
"/directories",
|
||||
"/directory_users",
|
||||
"/directory_groups"
|
||||
] do
|
||||
Bypass.stub(bypass, "GET", path, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.prepend_resp_headers([{"content-type", "application/json"}])
|
||||
|> Plug.Conn.send_resp(401, Jason.encode!(response))
|
||||
end)
|
||||
end
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :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
|
||||
end
|
||||
|
||||
test "persists the sync error on the provider", %{provider: provider} do
|
||||
bypass = Bypass.open()
|
||||
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
|
||||
for path <- [
|
||||
"/directories",
|
||||
"/directory_users",
|
||||
"/directory_groups"
|
||||
] do
|
||||
Bypass.stub(bypass, "GET", path, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.prepend_resp_headers([{"content-type", "application/json"}])
|
||||
|> Plug.Conn.send_resp(500, Jason.encode!(%{}))
|
||||
end)
|
||||
end
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :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 connecting to WorkOS"
|
||||
|
||||
for path <- [
|
||||
"/directories",
|
||||
"/directory_users",
|
||||
"/directory_groups"
|
||||
] do
|
||||
Bypass.stub(bypass, "GET", path, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.prepend_resp_headers([{"content-type", "application/json"}])
|
||||
|> Plug.Conn.send_resp(500, Jason.encode!(%{}))
|
||||
end)
|
||||
end
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :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 == "Error connecting to WorkOS"
|
||||
|
||||
cancel_bypass_expectations_check(bypass)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,20 @@ defmodule Domain.Auth.Adapters.Okta.Jobs.SyncDirectoryTest do
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when IdP sync is not enabled", %{account: account, provider: provider} do
|
||||
{:ok, _account} = Domain.Accounts.update_account(account, %{features: %{idp_sync: false}})
|
||||
|
||||
{:ok, pid} = Task.Supervisor.start_link()
|
||||
assert execute(%{task_supervisor: pid}) == :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 ==
|
||||
"IdP sync is not enabled in your subscription plan"
|
||||
end
|
||||
|
||||
test "syncs IdP data", %{provider: provider, bypass: bypass} do
|
||||
# bypass = Bypass.open(port: bypass.port)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule Domain.AuthTest do
|
||||
|
||||
assert Enum.sort(all_user_provisioned_provider_adapters!(account)) == [
|
||||
google_workspace: [enabled: true, sync: true],
|
||||
jumpcloud: [enabled: true, sync: true],
|
||||
microsoft_entra: [enabled: true, sync: true],
|
||||
okta: [enabled: true, sync: true],
|
||||
openid_connect: [enabled: true, sync: false]
|
||||
@@ -21,6 +22,7 @@ defmodule Domain.AuthTest do
|
||||
|
||||
assert Enum.sort(all_user_provisioned_provider_adapters!(account)) == [
|
||||
google_workspace: [enabled: false, sync: true],
|
||||
jumpcloud: [enabled: false, sync: true],
|
||||
microsoft_entra: [enabled: false, sync: true],
|
||||
okta: [enabled: false, sync: true],
|
||||
openid_connect: [enabled: true, sync: false]
|
||||
|
||||
@@ -27,10 +27,20 @@ defmodule Domain.Fixtures.Auth do
|
||||
Ecto.UUID.generate()
|
||||
end
|
||||
|
||||
def random_provider_identifier(%Domain.Auth.Provider{adapter: :jumpcloud}) 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
|
||||
|
||||
def random_workos_org_identifier() do
|
||||
chars = Range.to_list(?A..?Z) ++ Range.to_list(?0..?9)
|
||||
str = for _ <- 1..26, into: "", do: <<Enum.random(chars)>>
|
||||
"org_#{str}"
|
||||
end
|
||||
|
||||
def provider_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
name: "provider-#{unique_integer()}",
|
||||
@@ -139,6 +149,33 @@ defmodule Domain.Fixtures.Auth do
|
||||
{provider, bypass}
|
||||
end
|
||||
|
||||
def start_and_create_jumpcloud_provider(attrs \\ %{}) do
|
||||
bypass = Domain.Mocks.OpenIDConnect.discovery_document_server()
|
||||
|
||||
adapter_config =
|
||||
openid_connect_adapter_config(
|
||||
discovery_document_uri:
|
||||
"http://localhost:#{bypass.port}/.well-known/openid-configuration",
|
||||
scope: Domain.Auth.Adapters.JumpCloud.Settings.scope() |> Enum.join(" "),
|
||||
workos_org: %{
|
||||
"id" => Fixtures.WorkOS.random_workos_id(:org),
|
||||
"name" => Ecto.UUID.generate(),
|
||||
"object" => "organization",
|
||||
"domains" => [],
|
||||
"created_at" => DateTime.utc_now() |> DateTime.add(-1, :day),
|
||||
"updated_at" => DateTime.utc_now() |> DateTime.add(-1, :day),
|
||||
"allow_profiles_outside_organization" => false
|
||||
}
|
||||
)
|
||||
|
||||
provider =
|
||||
attrs
|
||||
|> Enum.into(%{adapter_config: adapter_config})
|
||||
|> create_jumpcloud_provider()
|
||||
|
||||
{provider, bypass}
|
||||
end
|
||||
|
||||
def create_openid_connect_provider(attrs \\ %{}) do
|
||||
attrs =
|
||||
%{
|
||||
@@ -247,6 +284,33 @@ defmodule Domain.Fixtures.Auth do
|
||||
)
|
||||
end
|
||||
|
||||
def create_jumpcloud_provider(attrs \\ %{}) do
|
||||
attrs =
|
||||
%{
|
||||
adapter: :jumpcloud,
|
||||
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
|
||||
|
||||
57
elixir/apps/domain/test/support/fixtures/workos.ex
Normal file
57
elixir/apps/domain/test/support/fixtures/workos.ex
Normal file
@@ -0,0 +1,57 @@
|
||||
defmodule Domain.Fixtures.WorkOS do
|
||||
use Domain.Fixture
|
||||
|
||||
def random_workos_id(id_type) do
|
||||
chars = Range.to_list(?A..?Z) ++ Range.to_list(?0..?9)
|
||||
random_str = for _ <- 1..26, into: "", do: <<Enum.random(chars)>>
|
||||
|
||||
case id_type do
|
||||
:org -> "org_#{random_str}"
|
||||
:directory -> "directory_#{random_str}"
|
||||
end
|
||||
end
|
||||
|
||||
def random_external_key() do
|
||||
chars = Range.to_list(?A..?Z) ++ Range.to_list(?a..?z) ++ Range.to_list(?0..?9)
|
||||
for _ <- 1..16, into: "", do: <<Enum.random(chars)>>
|
||||
end
|
||||
|
||||
def org_attrs(attrs \\ %{}) do
|
||||
default_org = %{
|
||||
id: random_workos_id(:org),
|
||||
name: Ecto.UUID.generate(),
|
||||
object: "organization",
|
||||
domains: [],
|
||||
created_at: DateTime.utc_now() |> DateTime.add(-1, :day),
|
||||
updated_at: DateTime.utc_now() |> DateTime.add(-1, :day),
|
||||
allow_profiles_outside_organization: false
|
||||
}
|
||||
|
||||
Map.merge(default_org, attrs)
|
||||
end
|
||||
|
||||
def directory_attrs(attrs \\ %{}) do
|
||||
default_directory = %{
|
||||
id: random_workos_id(:directory),
|
||||
object: "directory",
|
||||
external_key: random_external_key(),
|
||||
state: "linked",
|
||||
created_at: DateTime.utc_now() |> DateTime.add(-1, :day),
|
||||
updated_at: DateTime.utc_now() |> DateTime.add(-1, :day),
|
||||
name: Ecto.UUID.generate(),
|
||||
domain: nil,
|
||||
organization_id: random_workos_id(:org),
|
||||
type: "jump cloud scim v2.0"
|
||||
}
|
||||
|
||||
Map.merge(default_directory, attrs)
|
||||
end
|
||||
|
||||
def create_org(attrs \\ %{}) do
|
||||
struct(WorkOS.Organizations.Organization, attrs)
|
||||
end
|
||||
|
||||
def create_directory(attrs \\ %{}) do
|
||||
struct(WorkOS.DirectorySync.Directory, attrs)
|
||||
end
|
||||
end
|
||||
139
elixir/apps/domain/test/support/mocks/workos_directory.ex
Normal file
139
elixir/apps/domain/test/support/mocks/workos_directory.ex
Normal file
@@ -0,0 +1,139 @@
|
||||
defmodule Domain.Mocks.WorkOSDirectory do
|
||||
@directory_response %{
|
||||
"id" => "dir_12345",
|
||||
"object" => "directory",
|
||||
"external_key" => "zQmqj1NkBOajdOMO",
|
||||
"state" => "linked",
|
||||
"updated_at" => "2024-05-29T15:42:30.707Z",
|
||||
"created_at" => "2024-05-29T15:42:30.707Z",
|
||||
"name" => "Foo Directory",
|
||||
"domain" => nil,
|
||||
"organization_id" => "org_12345",
|
||||
"type" => "jump cloud scim v2.0"
|
||||
}
|
||||
|
||||
@directory_group_response %{
|
||||
"id" => "dir_grp_123",
|
||||
"object" => "directory_group",
|
||||
"idp_id" => "123",
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"name" => "Foo Group",
|
||||
"created_at" => "2021-10-27 15:21:50.640958",
|
||||
"updated_at" => "2021-12-13 12:15:45.531847",
|
||||
"raw_attributes" => %{
|
||||
"foo" => "bar"
|
||||
}
|
||||
}
|
||||
|
||||
@directory_user_response %{
|
||||
"id" => "user_123",
|
||||
"object" => "directory_user",
|
||||
"custom_attributes" => %{
|
||||
"custom" => true
|
||||
},
|
||||
"directory_id" => "dir_123",
|
||||
"organization_id" => "org_123",
|
||||
"emails" => [
|
||||
%{
|
||||
"primary" => true,
|
||||
"type" => "type",
|
||||
"value" => "jonsnow@workos.com"
|
||||
}
|
||||
],
|
||||
"groups" => [@directory_group_response],
|
||||
"idp_id" => "idp_foo",
|
||||
"first_name" => "Jon",
|
||||
"last_name" => "Snow",
|
||||
"job_title" => "Knight of the Watch",
|
||||
"raw_attributes" => %{},
|
||||
"state" => "active",
|
||||
"username" => "jonsnow",
|
||||
"created_at" => "2023-07-17T20:07:20.055Z",
|
||||
"updated_at" => "2023-07-17T20:07:20.055Z"
|
||||
}
|
||||
|
||||
def override_base_url(url) do
|
||||
config = Domain.Config.fetch_env!(:workos, WorkOS.Client)
|
||||
config = Keyword.put(config, :base_url, url)
|
||||
Domain.Config.put_env_override(:workos, WorkOS.Client, config)
|
||||
end
|
||||
|
||||
def mock_list_directories_endpoint(bypass, directories \\ nil) do
|
||||
directories_list_endpoint_path = "/directories"
|
||||
data = directories || [@directory_response]
|
||||
|
||||
resp = %{
|
||||
"data" => data,
|
||||
"list_metadata" => %{
|
||||
"before" => nil,
|
||||
"after" => nil
|
||||
}
|
||||
}
|
||||
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", directories_list_endpoint_path, fn conn ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
|
||||
conn
|
||||
|> Plug.Conn.prepend_resp_headers([{"content-type", "application/json"}])
|
||||
|> Plug.Conn.send_resp(200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def mock_list_users_endpoint(bypass, users \\ nil) do
|
||||
users_list_endpoint_path = "/directory_users"
|
||||
data = users || [@directory_user_response]
|
||||
|
||||
resp = %{
|
||||
"data" => data,
|
||||
"list_metadata" => %{
|
||||
"before" => nil,
|
||||
"after" => nil
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
|
||||
conn
|
||||
|> Plug.Conn.prepend_resp_headers([{"content-type", "application/json"}])
|
||||
|> Plug.Conn.send_resp(200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def mock_list_groups_endpoint(bypass, groups \\ nil) do
|
||||
groups_list_endpoint_path = "/directory_groups"
|
||||
data = groups || [@directory_group_response]
|
||||
|
||||
resp = %{
|
||||
"data" => data,
|
||||
"list_metadata" => %{
|
||||
"before" => nil,
|
||||
"after" => nil
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
|
||||
conn
|
||||
|> Plug.Conn.prepend_resp_headers([{"content-type", "application/json"}])
|
||||
|> Plug.Conn.send_resp(200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
bypass
|
||||
end
|
||||
end
|
||||
@@ -1132,6 +1132,12 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
def provider_icon(%{adapter: :jumpcloud} = assigns) do
|
||||
~H"""
|
||||
<img src={~p"/images/jumpcloud-logo.svg"} alt="JumpCloud Logo" {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
def provider_icon(%{adapter: :email} = assigns) do
|
||||
~H"""
|
||||
<.icon name="hero-envelope" {@rest} />
|
||||
|
||||
@@ -180,6 +180,36 @@ defmodule Web.Settings.IdentityProviders.Components do
|
||||
"""
|
||||
end
|
||||
|
||||
def status(
|
||||
%{
|
||||
provider: %{
|
||||
adapter: :jumpcloud,
|
||||
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/jumpcloud/#{@provider}/redirect"
|
||||
}
|
||||
>
|
||||
Connect IdP
|
||||
</.button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def status(
|
||||
%{
|
||||
provider: %{
|
||||
@@ -237,6 +267,7 @@ defmodule Web.Settings.IdentityProviders.Components do
|
||||
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(:jumpcloud), do: "JumpCloud"
|
||||
def adapter_name(:openid_connect), do: "OpenID Connect"
|
||||
|
||||
def view_provider(account, %{adapter: adapter} = provider)
|
||||
@@ -255,6 +286,9 @@ defmodule Web.Settings.IdentityProviders.Components do
|
||||
def view_provider(account, %{adapter: :okta} = provider),
|
||||
do: ~p"/#{account}/settings/identity_providers/okta/#{provider}"
|
||||
|
||||
def view_provider(account, %{adapter: :jumpcloud} = provider),
|
||||
do: ~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}"
|
||||
|
||||
def sync_status(%{provider: %{provisioner: :custom}} = assigns) do
|
||||
~H"""
|
||||
<div :if={not is_nil(@provider.last_synced_at)} class="flex items-center">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
defmodule Web.Settings.IdentityProviders.JumpCloud.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 SSO App in JumpCloud</: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/jumpcloud/#{@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
|
||||
type="hidden"
|
||||
label="Discovery Document URI"
|
||||
autocomplete="off"
|
||||
field={adapter_config_form[:discovery_document_uri]}
|
||||
value="https://oauth.id.jumpcloud.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</div>
|
||||
</.inputs_for>
|
||||
</div>
|
||||
|
||||
<.submit_button>
|
||||
Connect Identity Provider
|
||||
</.submit_button>
|
||||
</:content>
|
||||
</.step>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def scopes do
|
||||
"""
|
||||
openid
|
||||
profile
|
||||
email
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
defmodule Web.Settings.IdentityProviders.JumpCloud.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.JumpCloud
|
||||
|
||||
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/jumpcloud/#{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/jumpcloud/#{provider_id}/handle_callback"
|
||||
),
|
||||
code_verifier,
|
||||
code
|
||||
}
|
||||
|
||||
with {:ok, provider} <-
|
||||
Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject),
|
||||
{:ok, identity} <-
|
||||
JumpCloud.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,
|
||||
sync_disabled_at: nil
|
||||
},
|
||||
{:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do
|
||||
redirect(conn,
|
||||
to: ~p"/#{account}/settings/identity_providers/jumpcloud/#{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/jumpcloud/#{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/jumpcloud/#{provider_id}")
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "Provider does not exist.")
|
||||
|> redirect(to: ~p"/#{account}/settings/identity_providers/jumpcloud/#{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/jumpcloud/#{provider_id}")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "You may not authenticate to this account.")
|
||||
|> redirect(to: ~p"/#{account}/settings/identity_providers/jumpcloud/#{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/jumpcloud/#{provider_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
defmodule Web.Settings.IdentityProviders.JumpCloud.Edit do
|
||||
use Web, :live_view
|
||||
import Web.Settings.IdentityProviders.JumpCloud.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/jumpcloud/#{@provider}"}>
|
||||
<%= @provider.name %>
|
||||
</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/jumpcloud/#{@form.data}/edit"}>
|
||||
Edit
|
||||
</.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
|
||||
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
|
||||
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/jumpcloud/#{provider}/redirect"
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
defmodule Web.Settings.IdentityProviders.JumpCloud.New do
|
||||
use Web, :live_view
|
||||
import Web.Settings.IdentityProviders.JumpCloud.Components
|
||||
alias Domain.Auth
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
id = Ecto.UUID.generate()
|
||||
|
||||
changeset =
|
||||
Auth.new_provider(socket.assigns.account, %{
|
||||
name: "JumpCloud",
|
||||
adapter: :jumpcloud,
|
||||
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/jumpcloud/new"}>
|
||||
JumpCloud
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.section>
|
||||
<:title>
|
||||
Add a new JumpCloud ID Identity Provider
|
||||
</:title>
|
||||
<:help>
|
||||
For a more detailed guide on setting up Firezone with JumpCloud ID, please <.link
|
||||
href="https://www.firezone.dev/kb/authenticate/jumpcloud"
|
||||
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 = Map.put(attrs, "adapter", :jumpcloud)
|
||||
|
||||
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.put("id", socket.assigns.id)
|
||||
|> Map.put("adapter", :jumpcloud)
|
||||
|> update_adapter_config("workos_org", %{})
|
||||
# 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/jumpcloud/#{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 update_adapter_config(attrs, key, value) do
|
||||
updated_config =
|
||||
attrs["adapter_config"]
|
||||
|> Map.put_new(key, value)
|
||||
|
||||
Map.put(attrs, "adapter_config", updated_config)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,261 @@
|
||||
defmodule Web.Settings.IdentityProviders.JumpCloud.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, maybe_workos_directory} = maybe_fetch_directory(provider)
|
||||
|
||||
{: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,
|
||||
workos_directory: maybe_workos_directory,
|
||||
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/jumpcloud/#{@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/jumpcloud/#{@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 directory 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/jumpcloud/#{@provider}/redirect"}
|
||||
icon="hero-arrow-path"
|
||||
>
|
||||
Reconnect
|
||||
</.button>
|
||||
</:action>
|
||||
<:help>
|
||||
Directory sync is enabled for this provider. Users and groups will be synced every 10
|
||||
minutes on average, but could take longer for very large organizations.
|
||||
<.website_link href="/kb/authenticate/directory-sync">
|
||||
Read more
|
||||
</.website_link>
|
||||
about directory sync.
|
||||
</:help>
|
||||
<: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_portal_button :if={show_setup_sync_button?(@provider, @workos_directory)}>
|
||||
Setup Sync
|
||||
</.sync_portal_button>
|
||||
<div
|
||||
:if={not show_setup_sync_button?(@provider, @workos_directory)}
|
||||
class="lg:flex lg:gap-4"
|
||||
>
|
||||
<.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}
|
||||
/>
|
||||
<.sync_portal_button :if={provider_active?(@provider)}>
|
||||
Edit Sync
|
||||
</.sync_portal_button>
|
||||
<div
|
||||
:if={
|
||||
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
|
||||
not is_nil(@provider.sync_disabled_at) 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>
|
||||
</div>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row>
|
||||
<:label>Client ID</:label>
|
||||
<:value><%= @provider.adapter_config["client_id"] %></: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>
|
||||
</.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
|
||||
|
||||
def handle_event("setup_sync", _params, socket) do
|
||||
account = socket.assigns.account
|
||||
provider = socket.assigns.provider
|
||||
|
||||
return_url =
|
||||
url(~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}")
|
||||
|
||||
with {:ok, workos_portal} <-
|
||||
Domain.Auth.DirectorySync.WorkOS.create_portal_link(
|
||||
provider,
|
||||
return_url,
|
||||
socket.assigns.subject
|
||||
) do
|
||||
{:noreply, redirect(socket, external: workos_portal.link)}
|
||||
end
|
||||
end
|
||||
|
||||
attr :account, :any, required: true
|
||||
attr :provider, :any, required: true
|
||||
|
||||
attr :workos_directory, :any, required: true
|
||||
|
||||
def jumpcloud_sync_status(assigns) do
|
||||
~H"""
|
||||
|
||||
"""
|
||||
end
|
||||
|
||||
slot :inner_block
|
||||
|
||||
defp sync_portal_button(assigns) do
|
||||
~H"""
|
||||
<.button size="xs" phx-click="setup_sync">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp show_setup_sync_button?(provider, workos_directory) do
|
||||
provider_active?(provider) and !workos_directory
|
||||
end
|
||||
|
||||
defp provider_active?(provider) do
|
||||
is_nil(provider.deleted_at) and is_nil(provider.disabled_at)
|
||||
end
|
||||
|
||||
defp maybe_fetch_directory(provider) do
|
||||
Domain.Auth.DirectorySync.WorkOS.fetch_directory(provider)
|
||||
end
|
||||
end
|
||||
@@ -119,12 +119,21 @@ defmodule Web.Settings.IdentityProviders.New do
|
||||
end
|
||||
end
|
||||
|
||||
def next_step_path("jumpcloud" = provider, account) do
|
||||
if Domain.Accounts.idp_sync_enabled?(account) do
|
||||
~p"/#{account}/settings/identity_providers/jumpcloud/new"
|
||||
else
|
||||
~p"/#{account}/settings/identity_providers/openid_connect/new?provider=#{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
def pretty_print_provider(adapter) do
|
||||
case adapter do
|
||||
:openid_connect -> "OpenID Connect"
|
||||
:google_workspace -> "Google Workspace"
|
||||
:microsoft_entra -> "Microsoft EntraID"
|
||||
:okta -> "Okta"
|
||||
:jumpcloud -> "JumpCloud"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -260,6 +260,17 @@ defmodule Web.Router do
|
||||
get "/:provider_id/handle_callback", Connect, :handle_idp_callback
|
||||
end
|
||||
|
||||
scope "/jumpcloud", JumpCloud do
|
||||
live "/new", New
|
||||
live "/:provider_id", Show
|
||||
live "/:provider_id/edit", Edit
|
||||
live "/:provider_id/sync", Sync
|
||||
|
||||
# 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
|
||||
|
||||
17
elixir/apps/web/priv/static/images/jumpcloud-logo.svg
Normal file
17
elixir/apps/web/priv/static/images/jumpcloud-logo.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 55.744617 27.074409"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
width="55.744617"
|
||||
height="27.074409"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<path
|
||||
d="m 55.744579,19.490709 c 0,2.0113 -0.799,3.9403 -2.2212,5.3625 -1.4222,1.4222 -3.3511,2.2212 -5.3624,2.2212 h -2.0851 c -0.2174,-2.5279 -2.8258,-4.6774 -6.5129,-5.6757 0.9298,-0.9174 1.5658,-2.0906 1.8271,-3.3705 0.2614,-1.2798 0.1362,-2.6084 -0.3597,-3.8169 -0.4958,-1.2085 -1.3398,-2.2423 -2.4246,-2.9698 -1.0849,-0.7276 -2.3617,-1.116 -3.6679,-1.116 -1.3063,0 -2.582995,0.3884 -3.667895,1.116 -1.0849,0.7275 -1.9289,1.7613 -2.4247,2.9698 -0.4958,1.2085 -0.621,2.5371 -0.3597,3.8169 0.2613,1.2799 0.8974,2.4531 1.8272,3.3705 -1.7539,0.4194 -3.3841,1.2469 -4.7579,2.4152 -0.867,-0.4971 -1.7953,-0.8788 -2.7613,-1.1352 0.7994,-0.7835 1.3473,-1.7874 1.5739,-2.8836 0.2266,-1.0962 0.1216,-2.2351 -0.3015,-3.2714 -0.4232,-1.0363 -1.1455,-1.923 -2.0746,-2.5473 -0.9292,-0.6242 -2.0232,-0.9576 -3.1426,-0.9576 -1.1194,0 -2.2134,0.3334 -3.1426,0.9576 -0.9291,0.6243 -1.6514,1.511 -2.0745,2.5473 -0.4232,1.0363 -0.5282,2.1752 -0.3016,3.2714 0.2266,1.0962 0.7745,2.1001 1.5739,2.8836 -2.9062,0.8051 -5.0235,2.4152 -5.4744,4.3635 h -2.08508 c -1.96959,-0.0642 -3.83706,-0.8917 -5.20764,-2.3077 C 0.766284,23.318609 0,21.425109 0,19.454509 c 0,-1.9707 0.766284,-3.8641 2.136864,-5.28 1.37058,-1.416 3.23805,-2.2435 5.20764,-2.3077 0.96533,0.0034 1.92138,0.1892 2.81768,0.5475 0.6079,-1.3208 1.5833,-2.4385001 2.8097,-3.2195001 1.2264,-0.78095 2.6519,-1.19208 4.1058,-1.18421 h 0.5474 c 0.3794,-1.9636198 1.3455,-3.7659798 2.7706,-5.1691198 1.4251,-1.40314 3.2423,-2.34106003 5.2115,-2.68991003 1.9693,-0.348853 3.9981,-0.092238 5.8185,0.73597 1.820395,0.82820003 3.346795,2.18904003 4.377595,3.90283003 1.1792,-0.35822 2.4248,-0.44095 3.641,-0.24183 1.2161,0.19912 2.3703,0.67478 3.3737,1.39033 1.0033,0.71555 1.829,1.6518998 2.4133,2.7368798 0.5844,1.08496 0.9119,2.2896601 0.9573,3.5211601 1.1245,-0.3063 2.3045,-0.3486 3.4481,-0.1239 1.1435,0.2248 2.2197,0.7107 3.1446,1.4198 0.925,0.709 1.6736,1.6222 2.1876,2.6681 0.514,1.046 0.7795,2.1966 0.7757,3.362 z"
|
||||
fill="#002b49"
|
||||
id="path1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,309 @@
|
||||
defmodule Web.Live.Settings.IdentityProviders.JumpCloud.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_jumpcloud_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/jumpcloud/#{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/jumpcloud/#{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/jumpcloud/#{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/jumpcloud/#{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
|
||||
] |> 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_jumpcloud_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/jumpcloud/#{provider}/handle_callback",
|
||||
%{
|
||||
"state" => "XOXOX",
|
||||
"code" => "bar"
|
||||
}
|
||||
)
|
||||
|
||||
assert redirected_to(conn) ==
|
||||
~p"/#{account}/settings/identity_providers/jumpcloud/#{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_jumpcloud_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/jumpcloud/#{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/jumpcloud/#{provider.id}/handle_callback",
|
||||
%{
|
||||
"state" => state,
|
||||
"code" => "MyFakeCode"
|
||||
}
|
||||
)
|
||||
|
||||
assert redirected_to(conn) ==
|
||||
~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}"
|
||||
|
||||
assert provider = Repo.get(Domain.Auth.Provider, provider.id)
|
||||
assert provider.last_sync_error == nil
|
||||
assert provider.last_syncs_failed == 0
|
||||
assert provider.sync_disabled_at == nil
|
||||
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_jumpcloud_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/jumpcloud/#{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/jumpcloud/#{provider.id}/handle_callback",
|
||||
%{
|
||||
"state" => state,
|
||||
"code" => "MyFakeCode"
|
||||
}
|
||||
)
|
||||
|
||||
assert redirected_to(conn) ==
|
||||
~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}"
|
||||
|
||||
assert %{
|
||||
"preferred_locale" => "en_US",
|
||||
"sessions" => [{_account_id, _logged_in_at, session_token}]
|
||||
} = conn.private.plug_session
|
||||
|
||||
context = %Domain.Auth.Context{
|
||||
type: :browser,
|
||||
remote_ip: conn.remote_ip,
|
||||
user_agent: conn.assigns.user_agent,
|
||||
remote_ip_location_region: "Mexico",
|
||||
remote_ip_location_city: "Merida",
|
||||
remote_ip_location_lat: 37.7749,
|
||||
remote_ip_location_lon: -120.4194
|
||||
}
|
||||
|
||||
assert {:ok, subject} = Domain.Auth.authenticate(session_token, context)
|
||||
assert subject.identity.id == identity.id
|
||||
assert subject.identity.last_seen_user_agent == context.user_agent
|
||||
assert subject.identity.last_seen_remote_ip.address == context.remote_ip
|
||||
assert subject.identity.last_seen_at
|
||||
|
||||
assert provider = Repo.get(Domain.Auth.Provider, provider.id)
|
||||
|
||||
assert %{
|
||||
"access_token" => _,
|
||||
"claims" => %{},
|
||||
"expires_at" => _,
|
||||
"refresh_token" => _,
|
||||
"userinfo" => %{}
|
||||
} = provider.adapter_state
|
||||
|
||||
assert is_nil(provider.disabled_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
defmodule Web.Live.Settings.IdentityProviders.JumpCloud.EditTest 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_jumpcloud_provider(account: account)
|
||||
|
||||
%{
|
||||
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/jumpcloud/#{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/jumpcloud/#{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 "edits an existing provider on valid attrs", %{
|
||||
account: account,
|
||||
identity: identity,
|
||||
provider: provider,
|
||||
conn: conn
|
||||
} do
|
||||
bypass = Domain.Mocks.OpenIDConnect.discovery_document_server()
|
||||
discovery_document_uri = "http://localhost:#{bypass.port}/.well-known/openid-configuration"
|
||||
|
||||
adapter_config_attrs =
|
||||
Fixtures.Auth.openid_connect_adapter_config()
|
||||
|
||||
adapter_config_attrs =
|
||||
Map.drop(adapter_config_attrs, [
|
||||
"response_type",
|
||||
"discovery_document_uri",
|
||||
"scope"
|
||||
])
|
||||
|
||||
provider_attrs =
|
||||
Fixtures.Auth.provider_attrs(
|
||||
adapter: :jumpcloud,
|
||||
adapter_config: adapter_config_attrs
|
||||
)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}/edit")
|
||||
|
||||
form =
|
||||
form(lv, "form",
|
||||
provider: %{
|
||||
name: provider_attrs.name,
|
||||
adapter_config: provider_attrs.adapter_config
|
||||
}
|
||||
)
|
||||
|
||||
render_submit(form, %{
|
||||
provider: %{adapter_config: %{discovery_document_uri: discovery_document_uri}}
|
||||
})
|
||||
|
||||
assert provider = Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name)
|
||||
|
||||
assert_redirected(
|
||||
lv,
|
||||
~p"/#{account.id}/settings/identity_providers/jumpcloud/#{provider}/redirect"
|
||||
)
|
||||
|
||||
assert provider.name == provider_attrs.name
|
||||
assert provider.adapter == :jumpcloud
|
||||
|
||||
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
|
||||
adapter_config_attrs =
|
||||
Fixtures.Auth.openid_connect_adapter_config()
|
||||
|
||||
adapter_config_attrs =
|
||||
Map.drop(adapter_config_attrs, [
|
||||
"response_type",
|
||||
"discovery_document_uri",
|
||||
"scope"
|
||||
])
|
||||
|
||||
provider_attrs =
|
||||
Fixtures.Auth.provider_attrs(
|
||||
adapter: :jumpcloud,
|
||||
adapter_config: adapter_config_attrs
|
||||
)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{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" => "",
|
||||
"client_secret" => ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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][client_secret]" => ["can't be blank"]
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,164 @@
|
||||
defmodule Web.Live.Settings.IdentityProviders.JumpCloud.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/jumpcloud/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/jumpcloud/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()
|
||||
discovery_document_uri = "http://localhost:#{bypass.port}/.well-known/openid-configuration"
|
||||
|
||||
adapter_config_attrs = Fixtures.Auth.openid_connect_adapter_config()
|
||||
|
||||
adapter_config_attrs =
|
||||
Map.drop(adapter_config_attrs, [
|
||||
"response_type",
|
||||
"discovery_document_uri",
|
||||
"scope"
|
||||
])
|
||||
|
||||
provider_attrs =
|
||||
Fixtures.Auth.provider_attrs(
|
||||
adapter: :jumpcloud,
|
||||
adapter_config: adapter_config_attrs
|
||||
)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/new")
|
||||
|
||||
form =
|
||||
form(lv, "form",
|
||||
provider: %{
|
||||
name: provider_attrs.name,
|
||||
adapter_config: provider_attrs.adapter_config
|
||||
}
|
||||
)
|
||||
|
||||
render_submit(form, %{
|
||||
provider: %{adapter_config: %{discovery_document_uri: discovery_document_uri}}
|
||||
})
|
||||
|
||||
assert provider = Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name)
|
||||
|
||||
assert_redirected(
|
||||
lv,
|
||||
~p"/#{account.id}/settings/identity_providers/jumpcloud/#{provider}/redirect"
|
||||
)
|
||||
|
||||
assert provider.name == provider_attrs.name
|
||||
assert provider.adapter == :jumpcloud
|
||||
|
||||
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
|
||||
adapter_config_attrs =
|
||||
Fixtures.Auth.openid_connect_adapter_config()
|
||||
|
||||
adapter_config_attrs =
|
||||
Map.drop(adapter_config_attrs, [
|
||||
"response_type",
|
||||
"discovery_document_uri",
|
||||
"scope"
|
||||
])
|
||||
|
||||
provider_attrs =
|
||||
Fixtures.Auth.provider_attrs(
|
||||
adapter: :jumpcloud,
|
||||
adapter_config: adapter_config_attrs
|
||||
)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/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" => "",
|
||||
"client_secret" => ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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][client_secret]" => ["can't be blank"]
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,329 @@
|
||||
defmodule Web.Live.Settings.IdentityProviders.JumpCloud.ShowTest do
|
||||
use Web.ConnCase, async: true
|
||||
alias Domain.Mocks.WorkOSDirectory
|
||||
|
||||
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_jumpcloud_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/jumpcloud/#{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)
|
||||
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
{:ok, _lv, html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}")
|
||||
|
||||
assert html =~ "(deleted)"
|
||||
assert active_buttons(html) == []
|
||||
end
|
||||
|
||||
test "renders breadcrumbs item", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
identity: identity,
|
||||
conn: conn
|
||||
} do
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
{:ok, _lv, html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass, [])
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}")
|
||||
|
||||
table =
|
||||
lv
|
||||
|> element("#provider")
|
||||
|> render()
|
||||
|> vertical_table_to_map()
|
||||
|
||||
assert table["name"] == provider.name
|
||||
assert table["status"] == "Active"
|
||||
assert table["sync status"] == "Setup Sync"
|
||||
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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
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/jumpcloud/#{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/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
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/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass, [])
|
||||
|
||||
provider
|
||||
|> Ecto.Changeset.change(disabled_at: DateTime.utc_now())
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{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/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass, [])
|
||||
|
||||
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/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{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
|
||||
bypass = Bypass.open()
|
||||
WorkOSDirectory.override_base_url("http://localhost:#{bypass.port}")
|
||||
WorkOSDirectory.mock_list_directories_endpoint(bypass)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/settings/identity_providers/jumpcloud/#{provider}")
|
||||
|
||||
assert lv
|
||||
|> element("a", "Reconnect")
|
||||
|> render()
|
||||
|> Floki.attribute("href")
|
||||
|> hd() ==
|
||||
~p"/#{account.id}/settings/identity_providers/jumpcloud/#{provider}/redirect"
|
||||
end
|
||||
end
|
||||
@@ -244,6 +244,11 @@ config :tailwind,
|
||||
cd: Path.expand("../apps/web/assets", __DIR__)
|
||||
]
|
||||
|
||||
config :workos, WorkOS.Client,
|
||||
api_key: "sk_example_123456789",
|
||||
client_id: "client_123456789",
|
||||
baseurl: "https://api.workos.com"
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{Mix.env()}.exs"
|
||||
|
||||
@@ -103,3 +103,8 @@ config :phoenix, :stacktrace_depth, 20
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :web, Web.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
config :workos, WorkOS.Client,
|
||||
api_key: System.get_env("WORKOS_API_KEY"),
|
||||
client_id: System.get_env("WORKOS_CLIENT_ID"),
|
||||
baseurl: System.get_env("WORKOS_BASE_URL", "https://api.workos.com")
|
||||
|
||||
@@ -214,4 +214,8 @@ if config_env() == :prod do
|
||||
adapter: compile_config!(:outbound_email_adapter),
|
||||
from_email: compile_config!(:outbound_email_from)
|
||||
] ++ compile_config!(:outbound_email_adapter_opts)
|
||||
|
||||
config :workos, WorkOS.Client,
|
||||
api_key: compile_config!(:workos_api_key),
|
||||
client_id: compile_config!(:workos_client_id)
|
||||
end
|
||||
|
||||
@@ -80,3 +80,7 @@ config :ex_unit,
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :workos, WorkOS.Client,
|
||||
api_key: "sk_example_123456789",
|
||||
client_id: "client_123456789"
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"},
|
||||
"workos": {:git, "https://github.com/firezone/workos-elixir.git", "6d6995e2a765656a6834577837433393fac9b35b", [branch: "main"]},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
|
||||
}
|
||||
|
||||
@@ -297,6 +297,19 @@ locals {
|
||||
name = "DOCKER_REGISTRY"
|
||||
value = "ghcr.io/firezone"
|
||||
},
|
||||
# Directory Sync
|
||||
{
|
||||
name = "WORKOS_API_KEY"
|
||||
value = var.workos_api_key
|
||||
},
|
||||
{
|
||||
name = "WORKOS_CLIENT_ID"
|
||||
value = var.workos_client_id
|
||||
},
|
||||
{
|
||||
name = "WORKOS_BASE_URL"
|
||||
value = var.workos_base_url
|
||||
},
|
||||
# Billing system
|
||||
{
|
||||
name = "BILLING_ENABLED"
|
||||
|
||||
@@ -61,6 +61,20 @@ variable "stripe_default_price_id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "workos_api_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "workos_client_id" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "workos_base_url" {
|
||||
type = string
|
||||
}
|
||||
|
||||
# Version overrides
|
||||
#
|
||||
# This section should be used to bind a specific version of the Firezone component
|
||||
|
||||
@@ -265,6 +265,19 @@ locals {
|
||||
name = "AUTH_PROVIDER_ADAPTERS"
|
||||
value = "email,openid_connect,google_workspace,token,microsoft_entra,okta"
|
||||
},
|
||||
# Directory Sync
|
||||
{
|
||||
name = "WORKOS_API_KEY"
|
||||
value = var.workos_api_key
|
||||
},
|
||||
{
|
||||
name = "WORKOS_CLIENT_ID"
|
||||
value = var.workos_client_id
|
||||
},
|
||||
{
|
||||
name = "WORKOS_BASE_URL"
|
||||
value = var.workos_base_url
|
||||
},
|
||||
# Registry from which Docker install scripts pull from
|
||||
{
|
||||
name = "DOCKER_REGISTRY"
|
||||
|
||||
@@ -56,3 +56,17 @@ variable "firezone_client_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "workos_api_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "workos_client_id" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "workos_base_url" {
|
||||
type = string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user