Skip to content

Commit f18ec6e

Browse files
authored
Add Okta directory sync (firezone#3614)
Why: * To allow syncing of users/groups/memberships from an IDP to Firezone, a custom identify provider adapter needs to be created in the portal codebase at this time. The custom IDP adapter created in this commit is for Okta. * This commit also includes some additional tests for the Microsoft Entra IDP adapter. These tests were mistakenly overlooked when finishing the Entra adapter.
1 parent 830302a commit f18ec6e

File tree

41 files changed

+5165
-13
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+5165
-13
lines changed

docker-compose.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ services:
7979
DATABASE_USER: postgres
8080
DATABASE_PASSWORD: postgres
8181
# Auth
82-
AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace,microsoft_entra"
82+
AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace,microsoft_entra,okta"
8383
# Secrets
8484
TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
8585
TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"

elixir/apps/domain/lib/domain/auth/adapters.ex

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule Domain.Auth.Adapters do
77
openid_connect: Domain.Auth.Adapters.OpenIDConnect,
88
google_workspace: Domain.Auth.Adapters.GoogleWorkspace,
99
microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra,
10+
okta: Domain.Auth.Adapters.Okta,
1011
userpass: Domain.Auth.Adapters.UserPass
1112
}
1213

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
defmodule Domain.Auth.Adapters.Okta do
2+
use Supervisor
3+
alias Domain.Actors
4+
alias Domain.Auth.{Provider, Adapter}
5+
alias Domain.Auth.Adapters.OpenIDConnect
6+
alias Domain.Auth.Adapters.Okta
7+
require Logger
8+
9+
@behaviour Adapter
10+
@behaviour Adapter.IdP
11+
12+
def start_link(_init_arg) do
13+
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
14+
end
15+
16+
@impl true
17+
def init(_init_arg) do
18+
children = [
19+
Okta.APIClient,
20+
{Domain.Jobs, Okta.Jobs}
21+
]
22+
23+
Supervisor.init(children, strategy: :one_for_one)
24+
end
25+
26+
@impl true
27+
def capabilities do
28+
[
29+
provisioners: [:custom],
30+
default_provisioner: :custom,
31+
parent_adapter: :openid_connect
32+
]
33+
end
34+
35+
@impl true
36+
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
37+
changeset
38+
|> Domain.Validator.trim_change(:provider_identifier)
39+
|> Domain.Validator.copy_change(:provider_virtual_state, :provider_state)
40+
|> Ecto.Changeset.put_change(:provider_virtual_state, %{})
41+
end
42+
43+
@impl true
44+
def provider_changeset(%Ecto.Changeset{} = changeset) do
45+
changeset
46+
|> Domain.Changeset.cast_polymorphic_embed(:adapter_config,
47+
required: true,
48+
with: fn current_attrs, attrs ->
49+
Ecto.embedded_load(Okta.Settings, current_attrs, :json)
50+
|> Okta.Settings.Changeset.changeset(attrs)
51+
end
52+
)
53+
end
54+
55+
@impl true
56+
def ensure_provisioned(%Provider{} = provider) do
57+
{:ok, provider}
58+
end
59+
60+
@impl true
61+
def ensure_deprovisioned(%Provider{} = provider) do
62+
{:ok, provider}
63+
end
64+
65+
@impl true
66+
def sign_out(provider, identity, redirect_url) do
67+
OpenIDConnect.sign_out(provider, identity, redirect_url)
68+
end
69+
70+
@impl true
71+
def verify_and_update_identity(%Provider{} = provider, payload) do
72+
OpenIDConnect.verify_and_update_identity(provider, payload)
73+
end
74+
75+
def verify_and_upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, payload) do
76+
OpenIDConnect.verify_and_upsert_identity(actor, provider, payload)
77+
end
78+
79+
def refresh_access_token(%Provider{} = provider) do
80+
OpenIDConnect.refresh_access_token(provider)
81+
end
82+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
defmodule Domain.Auth.Adapters.Okta.APIClient do
2+
use Supervisor
3+
4+
@pool_name __MODULE__.Finch
5+
6+
def start_link(_init_arg) do
7+
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
8+
end
9+
10+
@impl true
11+
def init(_init_arg) do
12+
children = [
13+
{Finch,
14+
name: @pool_name,
15+
pools: %{
16+
default: pool_opts()
17+
}}
18+
]
19+
20+
Supervisor.init(children, strategy: :one_for_one)
21+
end
22+
23+
defp pool_opts do
24+
transport_opts =
25+
Domain.Config.fetch_env!(:domain, __MODULE__)
26+
|> Keyword.fetch!(:finch_transport_opts)
27+
28+
[conn_opts: [transport_opts: transport_opts]]
29+
end
30+
31+
def list_users(endpoint, api_token) do
32+
uri =
33+
URI.parse("#{endpoint}/api/v1/users")
34+
|> URI.append_query(
35+
URI.encode_query(%{
36+
"limit" => 200
37+
})
38+
)
39+
40+
headers = [
41+
{"Content-Type", "application/json; okta-response=omitCredentials,omitCredentialsLinks"}
42+
]
43+
44+
with {:ok, users} <- list_all(uri, headers, api_token) do
45+
active_users =
46+
Enum.filter(users, fn user ->
47+
user["status"] == "ACTIVE"
48+
end)
49+
50+
{:ok, active_users}
51+
end
52+
end
53+
54+
def list_groups(endpoint, api_token) do
55+
uri =
56+
URI.parse("#{endpoint}/api/v1/groups")
57+
|> URI.append_query(
58+
URI.encode_query(%{
59+
"limit" => 200
60+
})
61+
)
62+
63+
headers = []
64+
65+
list_all(uri, headers, api_token)
66+
end
67+
68+
def list_group_members(endpoint, api_token, group_id) do
69+
uri =
70+
URI.parse("#{endpoint}/api/v1/groups/#{group_id}/users")
71+
|> URI.append_query(
72+
URI.encode_query(%{
73+
"limit" => 200
74+
})
75+
)
76+
77+
headers = []
78+
79+
with {:ok, members} <- list_all(uri, headers, api_token) do
80+
enabled_members =
81+
Enum.filter(members, fn member ->
82+
member["status"] == "ACTIVE"
83+
end)
84+
85+
{:ok, enabled_members}
86+
end
87+
end
88+
89+
defp list_all(uri, headers, api_token, acc \\ []) do
90+
case list(uri, headers, api_token) do
91+
{:ok, list, nil} ->
92+
{:ok, List.flatten(Enum.reverse([list | acc]))}
93+
94+
{:ok, list, next_page_uri} ->
95+
URI.parse(next_page_uri)
96+
|> list_all(headers, api_token, [list | acc])
97+
98+
{:error, reason} ->
99+
{:error, reason}
100+
end
101+
end
102+
103+
defp list(uri, headers, api_token) do
104+
headers = headers ++ [{"Authorization", "Bearer #{api_token}"}]
105+
request = Finch.build(:get, uri, headers)
106+
107+
with {:ok, %Finch.Response{headers: headers, body: response, status: status}}
108+
when status in 200..299 <- Finch.request(request, @pool_name),
109+
{:ok, list} <- Jason.decode(response) do
110+
{:ok, list, fetch_next_link(headers)}
111+
else
112+
{:ok, %Finch.Response{status: status}} when status in 500..599 ->
113+
{:error, :retry_later}
114+
115+
{:ok, %Finch.Response{body: response, status: status}} ->
116+
case Jason.decode(response) do
117+
{:ok, json_response} ->
118+
{:error, {status, json_response}}
119+
120+
_error ->
121+
{:error, {status, response}}
122+
end
123+
124+
:error ->
125+
{:ok, [], nil}
126+
127+
other ->
128+
other
129+
end
130+
end
131+
132+
defp fetch_next_link(headers) do
133+
headers
134+
|> Enum.find(fn {name, value} ->
135+
name == "link" && String.contains?(value, "rel=\"next\"")
136+
end)
137+
|> parse_link_header()
138+
end
139+
140+
defp parse_link_header({_name, value}) do
141+
[raw_url | _] = String.split(value, ";")
142+
143+
raw_url
144+
|> String.replace_prefix("<", "")
145+
|> String.replace_suffix(">", "")
146+
end
147+
148+
defp parse_link_header(nil), do: nil
149+
end

0 commit comments

Comments
 (0)