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:
Brian Manifold
2024-06-12 11:45:33 -04:00
committed by GitHub
parent 04063874a3
commit 26d8f7eab3
43 changed files with 3453 additions and 2 deletions

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

@@ -5,7 +5,7 @@ defmodule Domain.Auth.Provider do
field :name, :string
field :adapter, Ecto.Enum,
values: ~w[email openid_connect google_workspace microsoft_entra 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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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