From 572c5671d0234cf9bf960cb1937f686702e006a0 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Thu, 29 Aug 2024 13:30:34 -0600 Subject: [PATCH] feat(portal): Use Service Accounts to sync Google Workspace directory (#6390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will need to update the docs for the website, some screens to show where the settings are: Screenshot 2024-08-19 at 1 04 23 PM Screenshot 2024-08-19 at 1 04 02 PM Related [#5959](https://github.com/firezone/firezone/issues/5959) --------- Signed-off-by: Andrew Dryga Co-authored-by: Jamil --- .../domain/auth/adapters/google_workspace.ex | 25 ++++ .../adapters/google_workspace/api_client.ex | 43 +++++++ .../google_workspace/jobs/sync_directory.ex | 21 +++- .../adapters/google_workspace/settings.ex | 21 ++++ .../google_workspace/settings/changeset.ex | 16 +++ elixir/apps/domain/mix.exs | 1 + .../jobs/sync_directory_test.exs | 42 +++++++ .../auth/adapters/google_workspace_test.exs | 58 ++++++++- elixir/apps/domain/test/domain/auth_test.exs | 6 +- .../apps/domain/test/support/fixtures/auth.ex | 64 +++++++++- .../mocks/google_workspace_directory.ex | 28 +++++ .../google_workspace/components.ex | 117 +++++++++++++++--- .../google_workspace/edit.ex | 4 + .../google_workspace/new.ex | 4 +- .../google_workspace/connect_test.exs | 1 + .../google_workspace/edit_test.exs | 22 +++- .../google_workspace/new_test.exs | 22 +++- elixir/config/config.exs | 1 + 18 files changed, 467 insertions(+), 29 deletions(-) diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex index 116811eaa..147e5791d 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex index 223a275d2..7a8ffc7b4 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex @@ -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__) diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs/sync_directory.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs/sync_directory.ex index 9d3638dd4..0ae0f13d3 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs/sync_directory.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs/sync_directory.ex @@ -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, diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex index 1f3a438d9..97727760e 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings/changeset.ex index dc6baf4c1..de81f7134 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings/changeset.ex @@ -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 diff --git a/elixir/apps/domain/mix.exs b/elixir/apps/domain/mix.exs index 05f87856d..d5e59795f 100644 --- a/elixir/apps/domain/mix.exs +++ b/elixir/apps/domain/mix.exs @@ -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"}, diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs index c270c5bd1..1ac3e3763 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs @@ -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. " <> diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs index 6ec1265cc..abae901f0 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs @@ -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() diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 1b2c58c47..694618ad8 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -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", %{ diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 128fa8924..fe2495722 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -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 diff --git a/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex b/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex index 1ab2903fe..c669625fb 100644 --- a/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex +++ b/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex @@ -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" diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex index ae06f3475..31e257888 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex @@ -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() %> + ><%= Enum.join(GoogleWorkspace.Settings.scope(), "\n") %>

Then click UPDATE.

@@ -152,13 +166,61 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do

Click CREATE. Copy the Client ID and Client secret - values from the next screen. + values from the next screen to the form below.

<.step> - <:title>Step 6. Configure Firezone + <:title>Step 6: Create service account with domain-wide delegation + <:content> +

+ Go to the + + Service Accounts + + page of the Google Cloud Console and click Create Service Account. +

+

+ Click on the created service account, then click the Keys + tab, Add Key + and select Create new key. Select JSON + and click Create. The contents of the downloaded JSON will be used for the + Service Account JSON Key + field of the form below. +

+

+ Go back to the Details + tab and copy the Unique ID + (OAuth 2 Client ID). You will need it for the next step. +

+

+ Sign in to the Google Workspace Admin Console and go to Security, then Access and Data Control, and then API Controls. Click Manage Domain Wide Delegation, + Add new + and paste the Unique ID + from the previous step to the Client ID + 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") %> +

+

+ Finally, click Authorize. +

+ + + + <.step> + <:title>Step 7. Configure Firezone <:content> <.base_error form={@form} field={:base} /> @@ -185,7 +247,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do required />

- The Client ID from the previous step. + The Client ID from Step 5.

@@ -197,7 +259,41 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do required />

- The Client secret from the previous step. + The Client secret from Step 5. +

+ + +
+ <.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 + /> +

+ The Service Account JSON Key from Step 6.

@@ -222,15 +318,4 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do """ 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 diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex index f6c30023f..49c3fec6c 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex @@ -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 = diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex index 9895aff5d..5293f6d3e 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex @@ -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()) diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs index 1e136921b..c4f564f49 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs @@ -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", diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs index 3eee8bb70..b587eb602 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs index d2b2d2d02..d8a54bf48 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs @@ -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 diff --git a/elixir/config/config.exs b/elixir/config/config.exs index b7652181b..7a3fe7e33 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -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,