feat(portal): Use Service Accounts to sync Google Workspace directory (#6390)

We will need to update the docs for the website, some screens to show
where the settings are:
<img width="1728" alt="Screenshot 2024-08-19 at 1 04 23 PM"
src="https://github.com/user-attachments/assets/88ebb06f-241d-44c8-90fa-258d0b78905e">
<img width="1436" alt="Screenshot 2024-08-19 at 1 04 02 PM"
src="https://github.com/user-attachments/assets/5f7a1011-5a53-4348-81cb-da804ee18bed">

Related [#5959](https://github.com/firezone/firezone/issues/5959)

---------

Signed-off-by: Andrew Dryga <andrew@dryga.com>
Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
Andrew Dryga
2024-08-29 13:30:34 -06:00
committed by GitHub
parent 4973ac9d4c
commit 572c5671d0
18 changed files with 467 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace do
alias Domain.Auth.{Provider, Adapter}
alias Domain.Auth.Adapters.OpenIDConnect
alias Domain.Auth.Adapters.GoogleWorkspace
alias Domain.Auth.Adapters.GoogleWorkspace.APIClient
require Logger
@behaviour Adapter
@@ -69,6 +70,30 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace do
OpenIDConnect.sign_out(provider, identity, redirect_url)
end
def fetch_service_account_token(%Provider{} = provider) do
unix_timestamp = :os.system_time(:seconds)
key = provider.adapter_config["service_account_json_key"]
jws = %{"alg" => "RS256", "typ" => "JWT"}
jwk = JOSE.JWK.from_pem(key["private_key"])
claim_set =
%{
"iss" => key["client_email"],
"scope" => Enum.join(GoogleWorkspace.Settings.scope(), " "),
"aud" => "https://oauth2.googleapis.com/token",
"exp" => unix_timestamp + 3600,
"iat" => unix_timestamp
}
|> Jason.encode!()
jwt =
JOSE.JWS.sign(jwk, claim_set, jws)
|> JOSE.JWS.compact()
|> elem(1)
APIClient.fetch_service_account_token(jwt)
end
@impl true
def verify_and_update_identity(%Provider{} = provider, payload) do
OpenIDConnect.verify_and_update_identity(provider, payload)

View File

@@ -34,6 +34,49 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
[conn_opts: [transport_opts: transport_opts]]
end
def fetch_service_account_token(jwt) do
endpoint =
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:token_endpoint)
token_endpoint = Path.join(endpoint, "token")
payload =
URI.encode_query(%{
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion" => jwt
})
request =
Finch.build(
:post,
token_endpoint,
[{"Content-Type", "application/x-www-form-urlencoded"}],
payload
)
with {:ok, %Finch.Response{body: response, status: status}} when status in 200..299 <-
Finch.request(request, @pool_name),
{:ok, %{"access_token" => access_token}} <- Jason.decode(response) do
{:ok, access_token}
else
{:ok, %Finch.Response{status: status}} when status in 500..599 ->
{:error, :retry_later}
{:ok, %Finch.Response{body: response, status: status}} ->
case Jason.decode(response) do
{:ok, json_response} ->
{:error, {status, json_response}}
_error ->
{:error, {status, response}}
end
other ->
other
end
end
def list_users(api_token) do
endpoint =
Domain.Config.fetch_env!(:domain, __MODULE__)

View File

@@ -23,7 +23,26 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectory do
end
def gather_provider_data(provider, task_supervisor_pid) do
access_token = provider.adapter_state["access_token"]
access_token =
with json_key when not is_nil(json_key) <-
provider.adapter_config["service_account_json_key"],
{:ok, access_token} <- GoogleWorkspace.fetch_service_account_token(provider) do
access_token
else
{:error, reason} ->
Logger.error("Failed to fetch service account token",
reason: inspect(reason),
account_id: provider.account_id,
account_slug: provider.account.slug,
provider_id: provider.id,
provider_adapter: provider.adapter
)
{:error, reason}
_other ->
provider.adapter_state["access_token"]
end
async_results =
DirectorySync.run_async_requests(task_supervisor_pid,

View File

@@ -3,6 +3,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Settings do
@scope ~w[
openid email profile
https://www.googleapis.com/auth/admin.directory.customer.readonly
https://www.googleapis.com/auth/admin.directory.orgunit.readonly
https://www.googleapis.com/auth/admin.directory.group.readonly
https://www.googleapis.com/auth/admin.directory.user.readonly
@@ -17,6 +18,26 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Settings do
field :client_id, :string
field :client_secret, :string
field :discovery_document_uri, :string, default: @discovery_document_uri
embeds_one :service_account_json_key, GoogleServiceAccountKey,
primary_key: false,
on_replace: :update do
field :type, :string
field :project_id, :string
field :private_key_id, :string
field :private_key, :string
field :client_email, :string
field :client_id, :string
field :auth_uri, :string
field :token_uri, :string
field :auth_provider_x509_cert_url, :string
field :client_x509_cert_url, :string
field :universe_domain, :string
end
end
def scope, do: @scope

View File

@@ -8,6 +8,12 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Settings.Changeset do
client_id client_secret
discovery_document_uri]a
@key_fields ~w[type project_id
private_key_id private_key
client_email client_id
auth_uri token_uri auth_provider_x509_cert_url client_x509_cert_url
universe_domain]a
def changeset(%Settings{} = settings, attrs) do
changeset =
settings
@@ -15,9 +21,19 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Settings.Changeset do
|> validate_required(@fields)
|> OpenIDConnect.Settings.Changeset.validate_discovery_document_uri()
|> validate_inclusion(:response_type, ~w[code])
|> cast_embed(:service_account_json_key,
with: &service_account_key_changeset/2,
required: true
)
Enum.reduce(Settings.scope(), changeset, fn scope, changeset ->
validate_format(changeset, :scope, ~r/#{scope}/, message: "must include #{scope} scope")
end)
end
def service_account_key_changeset(%Settings.GoogleServiceAccountKey{} = key, attrs) do
key
|> cast(attrs, @key_fields)
|> validate_required(@key_fields)
end
end

View File

@@ -55,6 +55,7 @@ defmodule Domain.MixProject do
# Auth-related deps
{:plug_crypto, "~> 2.0"},
{:jose, "~> 1.11"},
{:openid_connect,
github: "firezone/openid_connect", ref: "e4d9dca8ae43c765c00a7d3dfa12d6f24f5b3418"},
{:argon2_elixir, "~> 4.0"},

View File

@@ -32,6 +32,43 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do
"IdP sync is not enabled in your subscription plan"
end
test "uses service account token when it's available" do
bypass = Bypass.open()
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
GoogleWorkspaceDirectory.mock_groups_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_organization_units_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_users_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_token_endpoint(bypass)
{:ok, pid} = Task.Supervisor.start_link()
assert execute(%{task_supervisor: pid}) == :ok
assert_receive {:bypass_request,
%{req_headers: [{"authorization", "Bearer GOOGLE_0AUTH_ACCESS_TOKEN"} | _]}}
end
test "uses admin user token as a fallback", %{provider: provider} do
bypass = Bypass.open()
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
GoogleWorkspaceDirectory.mock_groups_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_organization_units_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_users_list_endpoint(bypass, [])
provider
|> Ecto.Changeset.change(
adapter_config: Map.put(provider.adapter_config, "service_account_json_key", nil)
)
|> Repo.update!()
{:ok, pid} = Task.Supervisor.start_link()
assert execute(%{task_supervisor: pid}) == :ok
assert_receive {:bypass_request,
%{req_headers: [{"authorization", "Bearer OIDC_ACCESS_TOKEN"} | _]}}
end
test "syncs IdP data", %{provider: provider} do
bypass = Bypass.open()
@@ -173,6 +210,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do
GoogleWorkspaceDirectory.mock_groups_list_endpoint(bypass, groups)
GoogleWorkspaceDirectory.mock_organization_units_list_endpoint(bypass, organization_units)
GoogleWorkspaceDirectory.mock_users_list_endpoint(bypass, users)
GoogleWorkspaceDirectory.mock_token_endpoint(bypass)
Enum.each(groups, fn group ->
GoogleWorkspaceDirectory.mock_group_members_list_endpoint(bypass, group["id"], members)
@@ -268,6 +306,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do
GoogleWorkspaceDirectory.mock_groups_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_organization_units_list_endpoint(bypass, [])
GoogleWorkspaceDirectory.mock_users_list_endpoint(bypass, users)
GoogleWorkspaceDirectory.mock_token_endpoint(bypass)
{:ok, pid} = Task.Supervisor.start_link()
assert execute(%{task_supervisor: pid}) == :ok
@@ -497,6 +536,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do
GoogleWorkspaceDirectory.mock_groups_list_endpoint(bypass, groups)
GoogleWorkspaceDirectory.mock_organization_units_list_endpoint(bypass, organization_units)
GoogleWorkspaceDirectory.mock_users_list_endpoint(bypass, users)
GoogleWorkspaceDirectory.mock_token_endpoint(bypass)
GoogleWorkspaceDirectory.mock_group_members_list_endpoint(bypass, "GROUP_ID1", two_members)
GoogleWorkspaceDirectory.mock_group_members_list_endpoint(bypass, "GROUP_ID2", one_member)
@@ -621,6 +661,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do
bypass = Bypass.open()
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
GoogleWorkspaceDirectory.mock_token_endpoint(bypass)
for path <- [
"/admin/directory/v1/users",
@@ -668,6 +709,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do
test "disables the sync on 401 response code", %{provider: provider} do
bypass = Bypass.open()
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
GoogleWorkspaceDirectory.mock_token_endpoint(bypass)
error_message =
"Admin SDK API has not been used in project XXXX before or it is disabled. " <>

View File

@@ -55,7 +55,8 @@ defmodule Domain.Auth.Adapters.GoogleWorkspaceTest do
assert errors_on(changeset) == %{
adapter_config: %{
client_id: ["can't be blank"],
client_secret: ["can't be blank"]
client_secret: ["can't be blank"],
service_account_json_key: ["can't be blank"]
}
}
end
@@ -71,7 +72,20 @@ defmodule Domain.Auth.Adapters.GoogleWorkspaceTest do
adapter_config: %{
client_id: "client_id",
client_secret: "client_secret",
discovery_document_uri: discovery_document_url
discovery_document_uri: discovery_document_url,
service_account_json_key: %{
type: "service_account",
project_id: "project_id",
private_key_id: "private_key_id",
private_key: "private_key",
client_email: "client_email",
client_id: "client_id",
auth_uri: "auth_uri",
token_uri: "token_uri",
auth_provider_x509_cert_url: "auth_provider_x509_cert_url",
client_x509_cert_url: "client_x509_cert_url",
universe_domain: "universe_domain"
}
}
)
@@ -90,6 +104,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspaceTest do
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/admin.directory.customer.readonly",
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
"https://www.googleapis.com/auth/admin.directory.group.readonly",
"https://www.googleapis.com/auth/admin.directory.user.readonly"
@@ -99,7 +114,20 @@ defmodule Domain.Auth.Adapters.GoogleWorkspaceTest do
"response_type" => "code",
"client_id" => "client_id",
"client_secret" => "client_secret",
"discovery_document_uri" => discovery_document_url
"discovery_document_uri" => discovery_document_url,
"service_account_json_key" => %{
type: "service_account",
client_id: "client_id",
project_id: "project_id",
private_key: "private_key",
private_key_id: "private_key_id",
client_email: "client_email",
auth_uri: "auth_uri",
token_uri: "token_uri",
auth_provider_x509_cert_url: "auth_provider_x509_cert_url",
client_x509_cert_url: "client_x509_cert_url",
universe_domain: "universe_domain"
}
}
end
end
@@ -111,6 +139,30 @@ defmodule Domain.Auth.Adapters.GoogleWorkspaceTest do
end
end
describe "fetch_service_account_token/1" do
test "generates a valid JWT" do
Bypass.open()
|> Mocks.GoogleWorkspaceDirectory.mock_token_endpoint()
{provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider()
provider = Repo.get!(Auth.Provider, provider.id)
assert fetch_service_account_token(provider) == {:ok, "GOOGLE_0AUTH_ACCESS_TOKEN"}
end
test "returns error when API is not available" do
Bypass.open()
|> Mocks.GoogleWorkspaceDirectory.mock_token_endpoint()
|> Bypass.down()
{provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider()
provider = Repo.get!(Auth.Provider, provider.id)
assert fetch_service_account_token(provider) ==
{:error, %Mint.TransportError{reason: :econnrefused}}
end
end
describe "verify_and_update_identity/2" do
setup do
account = Fixtures.Accounts.create_account()

View File

@@ -639,7 +639,11 @@ defmodule Domain.AuthTest do
assert {:error, changeset} = create_provider(account, attrs)
refute changeset.valid?
assert errors_on(changeset) == %{adapter: ["is invalid"]}
assert errors_on(changeset) == %{
adapter: ["is invalid"],
adapter_config: %{service_account_json_key: ["can't be blank"]}
}
end
test "creates a provider", %{

View File

@@ -2,6 +2,38 @@ defmodule Domain.Fixtures.Auth do
use Domain.Fixture
alias Domain.Auth
# this key is revoked so don't bother trying to use it
@google_workspace_private_key """
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZU+IlZMT1ExqS
LAi7Fa2bGYiGSFIvbVoOVvu8VZyOAR3Bjfe2TiLzVTc35+D5fYzQftx7sC0ZF6Ub
ZSK5mgBi0LcVw8xsMcDhroD0MZdE5E1Lg/tvCdYJCkWFsvCHk8yN40hPgw2lB9Bu
xJ4uV5agl8zAkEr+Y8ck0BFY3aK5uyA5McdmakkEUCYRfUaoRCP4y+kR22PJJnYN
yYma4nLk6b3OwMs58z5U0N2tmDj8o8zWPSlh4HJgMmOnwtl1EjZ9ZlwjENhzooL2
E00gFglm8Lgj34HZp6zhF3bhiCQz0j06puLScXAsLDa5AMf4mBVNsefG59lGZLd4
HEaRoxrjAgMBAAECggEAHdiDO84qvJ3UXUGvDWPB4GAPADyRquO5VPM/m0B68fVr
qmKNJnJ9QSqETiCX3VjAEVGwb28yyCCfJf8AzGoayyFfkiAD6cehiQyj02TX0jQy
i5GMXufmPuo98DGNuoZdmfz09W1IOaiUvQsO02x/SJFj7NPplS0s9ZB+3/J8m3Rx
OmYzWg27zV5yITSE4N5FVfK7zfOHzFSdo+yXULRS8ZfzdQeQBFqlnWYSMe9P3QlG
kJDyB0JULGcUfpcKQfcI//AMSFjhNn5CngYCU4Qedsm04PmbQr73qMZdbmzLw1Nq
NToSwc9SsH2rUBjwffdUK8JNE2wY8JVF96pqX3C8QQKBgQDNQ2o5HZmI2vGqVG0G
8/cDVDoJuEjgVPuYAeCHjfjXKR/AKanUTu0Pv/Q45K419T4IdMbOcqr4TvkDHsgZ
qQ7Uus+soDz6kY5oyYL43NBS1XAeTmjBkyKT4+k3goUg1+rPyKEATT3dXwT377CS
CC3HQE4mZ4RFhEocxku0l/M1oQKBgQC/Ohm3f2/tod5xeJSXfdC0mHKcxcTjQiax
pYWHbr+YH4GRBTZUNpCMIoYpSjLCoCXcQ5yhxK3K2BEp/5t44OrmfI1o91Xz2XXJ
x0A7q27umTRug8J7E3GaoTDutFBUP5C0nJSQgdQaTOAMzZpJqtM27tFJYAHxI2gS
0cEeFsM6AwKBgH/r0qhTvRqgMFnRkbzyj++gLyddlPVRoRZjnRV9siYNN/9fN7rb
kTvuifpm8fcopodIl5mTtt9XADMknNn5FQgYgFJ57mbODa1aYGhN3Pqyj9QjU3/H
/ZWjRPXWPrdwOKNTyprQiIyMqiEGXMk1laoGdm3St4lHX5S9M/MRe33hAoGBAJXi
TFXvpSN1RI1cHdu/2d4zv2HyAai/KOUE/+xvee0ahMvOcg7/1byBMvcaGT9Dl2lV
9Wc2aaIcSRfKKWpNoNCXv58Ofmhrgk9txYL/lCugGeCllcIyM1EoFtqCqpPeXuWx
9SBvInia2OIwJUaohnUAKzp/7gW74s8daWjUHqFRAoGAJ6JJYh749pfDYB4LKwia
R9Iyld0qDPR6FXY0ZkOWKczHM2OFjhTT5LglNhoso4zavakyIRmWH8y1tiQnSO/m
XI2ckSJQwxpnezLFkP2poJaaM4UqbvRFpXAvUOwvMLpbN57WSngm7Gsm6c9dKvZl
7aghWWogzrdN9hMNjXRevao=
-----END PRIVATE KEY-----
"""
def user_password, do: "Hello w0rld!"
def remote_ip, do: {100, 64, 100, 58}
def user_agent, do: "iOS/12.5 (iPhone) connlib/1.3.0"
@@ -100,7 +132,21 @@ defmodule Domain.Fixtures.Auth do
openid_connect_adapter_config(
discovery_document_uri:
"http://localhost:#{bypass.port}/.well-known/openid-configuration",
scope: Domain.Auth.Adapters.GoogleWorkspace.Settings.scope() |> Enum.join(" ")
scope: Domain.Auth.Adapters.GoogleWorkspace.Settings.scope() |> Enum.join(" "),
service_account_json_key: %{
type: "service_account",
project_id: "firezone-test",
private_key_id: "e1fc5c12b490aaa1602f3de9133551952b749db3",
private_key: @google_workspace_private_key,
client_email: "firezone-idp-sync@firezone-test-391719.iam.gserviceaccount.com",
client_id: "110986447653011314480",
auth_uri: "https://accounts.google.com/o/oauth2/auth",
token_uri: "https://oauth2.googleapis.com/token",
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
client_x509_cert_url:
"https://www.googleapis.com/robot/v1/metadata/x509/firezone-idp-sync%40firezone-test-111111.iam.gserviceaccount.com",
universe_domain: "googleapis.com"
}
)
provider =
@@ -225,7 +271,21 @@ defmodule Domain.Fixtures.Auth do
"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"
"claims" => "openid email profile offline_access",
"service_account_json_key" => %{
"type" => "service_account",
"project_id" => "firezone-test",
"private_key_id" => "e1fc5c12b490aaa1602f3de9133551952b749db3",
"private_key" => @google_workspace_private_key,
"client_email" => "firezone-idp-sync@firezone-test-391719.iam.gserviceaccount.com",
"client_id" => "110986447653011314480",
"auth_uri" => "https://accounts.google.com/o/oauth2/auth",
"token_uri" => "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url" => "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url" =>
"https://www.googleapis.com/robot/v1/metadata/x509/firezone-idp-sync%40firezone-test-111111.iam.gserviceaccount.com",
"universe_domain" => "googleapis.com"
}
}
)
end

View File

@@ -7,6 +7,34 @@ defmodule Domain.Mocks.GoogleWorkspaceDirectory do
Domain.Config.put_env_override(:domain, GoogleWorkspace.APIClient, config)
end
def override_token_endpoint(url) do
config = Domain.Config.fetch_env!(:domain, GoogleWorkspace.APIClient)
config = Keyword.put(config, :token_endpoint, url)
Domain.Config.put_env_override(:domain, GoogleWorkspace.APIClient, config)
end
def mock_token_endpoint(bypass) do
token_endpoint_path = "/token"
resp = %{
"access_token" => "GOOGLE_0AUTH_ACCESS_TOKEN",
"expires_in" => 3599,
"token_type" => "Bearer"
}
test_pid = self()
Bypass.stub(bypass, "POST", token_endpoint_path, fn conn ->
conn = Plug.Conn.fetch_query_params(conn)
send(test_pid, {:bypass_request, conn})
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
end)
override_token_endpoint("http://localhost:#{bypass.port}/")
bypass
end
def mock_users_list_endpoint(bypass, users \\ nil) do
users_list_endpoint_path = "/admin/directory/v1/users"

View File

@@ -1,5 +1,19 @@
defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do
use Web, :component_library
alias Domain.Auth.Adapters.GoogleWorkspace
def map_provider_form_attrs(attrs) do
attrs
|> Map.put("adapter", :google_workspace)
|> Map.update("adapter_config", %{}, fn adapter_config ->
Map.update(adapter_config, "service_account_json_key", nil, fn service_account_json_key ->
case Jason.decode(service_account_json_key) do
{:ok, map} -> map
{:error, _} -> service_account_json_key
end
end)
end)
end
def provider_form(assigns) do
~H"""
@@ -104,7 +118,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do
id="oauth-scopes"
class="w-full text-xs mb-4 whitespace-pre-line rounded"
phx-no-format
><%= scopes() %></.code_block>
><%= Enum.join(GoogleWorkspace.Settings.scope(), "\n") %></.code_block>
<p class="mb-4">
Then click <strong>UPDATE</strong>.
</p>
@@ -152,13 +166,61 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do
<p class="mb-4">
Click <strong>CREATE</strong>. Copy the <strong>Client ID</strong>
and <strong>Client secret</strong>
values from the next screen.
values from the next screen to the form below.
</p>
</:content>
</.step>
<.step>
<:title>Step 6. Configure Firezone</:title>
<:title>Step 6: Create service account with domain-wide delegation</:title>
<:content>
<p class="mb-4">
Go to the
<a
href="https://console.cloud.google.com/iam-admin/serviceaccounts"
target="_blank"
class={link_style()}
>
<strong>Service Accounts</strong>
</a>
page of the Google Cloud Console and click <strong>Create Service Account</strong>.
</p>
<p class="mb-4">
Click on the created service account, then click the <strong>Keys</strong>
tab, <strong>Add Key</strong>
and select <strong>Create new key</strong>. Select <strong>JSON</strong>
and click <strong>Create</strong>. The contents of the downloaded JSON will be used for the
<strong>Service Account JSON Key</strong>
field of the form below.
</p>
<p class="mb-4">
Go back to the <strong>Details</strong>
tab and copy the <strong>Unique ID</strong>
(OAuth 2 Client ID). You will need it for the next step.
</p>
<p class="mb-4">
Sign in to the Google Workspace Admin Console and go to <strong>Security</strong>, then <strong>Access and Data Control</strong>, and then <a
href="https://admin.google.com/ac/owl"
target="_blank"
class={link_style()}
><strong>API Controls</strong></a>. Click <strong>Manage Domain Wide Delegation</strong>,
<strong>Add new</strong>
and paste the <strong>Unique ID</strong>
from the previous step to the <strong>Client ID</strong>
field and add the following scopes: <.code_block
id="oauth-scopes"
class="w-full text-xs mb-4 whitespace-pre-line rounded"
phx-no-format
><%= Enum.join(GoogleWorkspace.Settings.scope(), "\n") %></.code_block>
</p>
<p class="mb-4">
Finally, click <strong>Authorize</strong>.
</p>
</:content>
</.step>
<.step>
<:title>Step 7. Configure Firezone</:title>
<:content>
<.base_error form={@form} field={:base} />
@@ -185,7 +247,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do
required
/>
<p class="mt-2 text-xs text-neutral-500">
The Client ID from the previous step.
The Client ID from Step 5.
</p>
</div>
@@ -197,7 +259,41 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do
required
/>
<p class="mt-2 text-xs text-neutral-500">
The Client secret from the previous step.
The Client secret from Step 5.
</p>
</div>
<div>
<.input
type="textarea"
label="Service Account JSON Key"
autocomplete="off"
field={adapter_config_form[:service_account_json_key]}
placeholder='{"type":"service_account","project_id":...}'
value={
case adapter_config_form[:service_account_json_key].value do
nil ->
nil
%Ecto.Changeset{} = changeset ->
changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
|> Jason.encode!()
%GoogleWorkspace.Settings.GoogleServiceAccountKey{} = struct ->
struct
|> Map.from_struct()
|> Jason.encode!()
binary when is_binary(binary) ->
binary
end
}
required
/>
<p class="mt-2 text-xs text-neutral-500">
The Service Account JSON Key from Step 6.
</p>
</div>
</.inputs_for>
@@ -222,15 +318,4 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do
</div>
"""
end
def scopes do
"""
openid
profile
email
https://www.googleapis.com/auth/admin.directory.orgunit.readonly
https://www.googleapis.com/auth/admin.directory.group.readonly
https://www.googleapis.com/auth/admin.directory.user.readonly
"""
end
end

View File

@@ -44,6 +44,8 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Edit do
end
def handle_event("change", %{"provider" => attrs}, socket) do
attrs = map_provider_form_attrs(attrs)
changeset =
Auth.change_provider(socket.assigns.provider, attrs)
|> Map.put(:action, :insert)
@@ -52,6 +54,8 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Edit do
end
def handle_event("submit", %{"provider" => attrs}, socket) do
attrs = map_provider_form_attrs(attrs)
with {:ok, provider} <-
Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) do
socket =

View File

@@ -52,7 +52,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.New do
end
def handle_event("change", %{"provider" => attrs}, socket) do
attrs = Map.put(attrs, "adapter", :google_workspace)
attrs = map_provider_form_attrs(attrs)
changeset =
Auth.new_provider(socket.assigns.account, attrs)
@@ -65,7 +65,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.New do
attrs =
attrs
|> Map.put("id", socket.assigns.id)
|> Map.put("adapter", :google_workspace)
|> map_provider_form_attrs()
# 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())

View File

@@ -94,6 +94,7 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.Connect do
"openid " <>
"email " <>
"profile " <>
"https://www.googleapis.com/auth/admin.directory.customer.readonly " <>
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly " <>
"https://www.googleapis.com/auth/admin.directory.group.readonly " <>
"https://www.googleapis.com/auth/admin.directory.user.readonly",

View File

@@ -54,6 +54,7 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.EditTest do
"provider[adapter_config][_persistent_id]",
"provider[adapter_config][client_id]",
"provider[adapter_config][client_secret]",
"provider[adapter_config][service_account_json_key]",
"provider[name]"
]
end
@@ -68,7 +69,23 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.EditTest do
adapter_config_attrs =
Fixtures.Auth.openid_connect_adapter_config(
discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration"
discovery_document_uri:
"http://localhost:#{bypass.port}/.well-known/openid-configuration",
service_account_json_key:
Jason.encode!(%{
"type" => "service_account",
"project_id" => "firezone-test",
"private_key_id" => "e1fc5c12b490aaa1602f3de9133551952b749db3",
"private_key" => "...",
"client_email" => "firezone-idp-sync@firezone-test-391719.iam.gserviceaccount.com",
"client_id" => "110986447653011314480",
"auth_uri" => "https://accounts.google.com/o/oauth2/auth",
"token_uri" => "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url" => "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url" =>
"https://www.googleapis.com/robot/v1/metadata/x509/firezone-idp-sync%40firezone-test-111111.iam.gserviceaccount.com",
"universe_domain" => "googleapis.com"
})
)
adapter_config_attrs =
@@ -162,7 +179,8 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.EditTest do
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_id]" => ["can't be blank"],
"provider[adapter_config][service_account_json_key]" => ["is invalid"]
}
end)
end

View File

@@ -46,6 +46,7 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.NewTest do
"provider[adapter_config][_persistent_id]",
"provider[adapter_config][client_id]",
"provider[adapter_config][client_secret]",
"provider[adapter_config][service_account_json_key]",
"provider[name]"
]
end
@@ -59,7 +60,23 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.NewTest do
adapter_config_attrs =
Fixtures.Auth.openid_connect_adapter_config(
discovery_document_uri: "http://localhost:#{bypass.port}/.well-known/openid-configuration"
discovery_document_uri:
"http://localhost:#{bypass.port}/.well-known/openid-configuration",
service_account_json_key:
Jason.encode!(%{
"type" => "service_account",
"project_id" => "firezone-test",
"private_key_id" => "e1fc5c12b490aaa1602f3de9133551952b749db3",
"private_key" => "...",
"client_email" => "firezone-idp-sync@firezone-test-391719.iam.gserviceaccount.com",
"client_id" => "110986447653011314480",
"auth_uri" => "https://accounts.google.com/o/oauth2/auth",
"token_uri" => "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url" => "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url" =>
"https://www.googleapis.com/robot/v1/metadata/x509/firezone-idp-sync%40firezone-test-111111.iam.gserviceaccount.com",
"universe_domain" => "googleapis.com"
})
)
adapter_config_attrs =
@@ -154,7 +171,8 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.NewTest do
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_id]" => ["can't be blank"],
"provider[adapter_config][service_account_json_key]" => ["is invalid"]
}
end)
end

View File

@@ -45,6 +45,7 @@ config :domain, Domain.Analytics,
config :domain, Domain.Auth.Adapters.GoogleWorkspace.APIClient,
endpoint: "https://admin.googleapis.com",
token_endpoint: "https://oauth2.googleapis.com",
finch_transport_opts: []
config :domain, Domain.Auth.Adapters.MicrosoftEntra.APIClient,