From 2eeff14288043407f41e6efdedb75b54d72a7a69 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Mon, 8 Apr 2024 02:14:42 -0600 Subject: [PATCH] support workload identity (#170) --- lib/goth/config.ex | 4 ++ lib/goth/token.ex | 72 +++++++++++++++++++ .../test-credentials-workload-identity.json | 13 ++++ test/data/workload-identity-token | 1 + test/goth/config_test.exs | 34 +++++++++ test/goth/token_test.exs | 37 ++++++++++ 6 files changed, 161 insertions(+) create mode 100644 test/data/test-credentials-workload-identity.json create mode 100644 test/data/workload-identity-token diff --git a/lib/goth/config.ex b/lib/goth/config.ex index 9e6561c..82439dc 100644 --- a/lib/goth/config.ex +++ b/lib/goth/config.ex @@ -304,6 +304,10 @@ defmodule Goth.Config do Map.put(map, "token_source", :oauth_refresh) end + defp set_token_source(%{"type" => "external_account"} = map) do + Map.put(map, "token_source", :workload_identity) + end + defp set_token_source(list) when is_list(list) do Enum.map(list, fn config -> set_token_source(config) diff --git a/lib/goth/token.ex b/lib/goth/token.ex index 8b52bce..ad708ae 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -255,6 +255,23 @@ defmodule Goth.Token do {:ok, :metadata} -> request(%{config | source: {:metadata, opts}}) + + {:ok, :workload_identity} -> + {:ok, url} = Goth.Config.get(:token_url) + {:ok, audience} = Goth.Config.get(:audience) + {:ok, subject_token_type} = Goth.Config.get(:subject_token_type) + {:ok, credential_source} = Goth.Config.get(:credential_source) + {:ok, service_account_impersonation_url} = Goth.Config.get(:service_account_impersonation_url) + + credentials = %{ + "token_url" => url, + "audience" => audience, + "subject_token_type" => subject_token_type, + "credential_source" => credential_source, + "service_account_impersonation_url" => service_account_impersonation_url + } + + request(%{config | source: {:workload_identity, credentials}}) end end @@ -333,6 +350,31 @@ defmodule Goth.Token do end end + defp request(%{source: {:workload_identity, credentials}} = config) do + %{ + "token_url" => token_url, + "audience" => audience, + "subject_token_type" => subject_token_type, + "credential_source" => credential_source + } = credentials + + headers = [{"Content-Type", "application/x-www-form-urlencoded"}] + + body = + URI.encode_query(%{ + "audience" => audience, + "grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type" => "urn:ietf:params:oauth:token-type:access_token", + "scope" => "https://www.googleapis.com/auth/cloud-platform", + "subject_token_type" => subject_token_type, + "subject_token" => subject_token_from_credential_source(credential_source) + }) + + response = request(config.http_client, method: :post, url: token_url, headers: headers, body: body) + + handle_workload_identity_response(response, config) + end + defp metadata_options(options) do account = Keyword.get(options, :account, "default") audience = Keyword.get(options, :audience, nil) @@ -348,12 +390,32 @@ defmodule Goth.Token do {url, audience} end + defp subject_token_from_credential_source(%{"file" => file, "format" => %{"type" => "text"}}) do + File.read!(file) + end + defp handle_jwt_response({:ok, %{status: 200, body: body}}) do {:ok, build_token(%{"id_token" => body})} end defp handle_jwt_response(response), do: handle_response(response) + defp handle_workload_identity_response( + {:ok, %{status: 200, body: body}}, + %{source: {:workload_identity, credentials}} = config + ) do + url = Map.get(credentials, "service_account_impersonation_url") + %{"access_token" => token, "token_type" => type} = Jason.decode!(body) + + headers = [{"content-type", "text/json"}, {"Authorization", "#{type} #{token}"}] + body = Jason.encode!(%{scope: "https://www.googleapis.com/auth/cloud-platform"}) + response = request(config.http_client, method: :post, url: url, headers: headers, body: body) + + handle_response(response) + end + + defp handle_workload_identity_response(response, _config), do: handle_response(response) + defp handle_response({:ok, %{status: 200, body: body}}) when is_map(body) do {:ok, build_token(body)} end @@ -418,6 +480,16 @@ defmodule Goth.Token do } end + defp build_token(%{"accessToken" => token, "expireTime" => expire_time}) do + {:ok, datetime, 0} = DateTime.from_iso8601(expire_time) + + %__MODULE__{ + expires: DateTime.to_unix(datetime), + token: token, + type: "Bearer" + } + end + defp request({:finch, extra_options}, options) do Goth.__finch__(options ++ extra_options) end diff --git a/test/data/test-credentials-workload-identity.json b/test/data/test-credentials-workload-identity.json new file mode 100644 index 0000000..d6a860a --- /dev/null +++ b/test/data/test-credentials-workload-identity.json @@ -0,0 +1,13 @@ +{ + "audience": "//iam.googleapis.com/projects/my-project/locations/global/workloadIdentityPools/my-cluster/providers/my-provider", + "credential_source": { + "file": "test/data/workload-identity-token", + "format": { + "type": "text" + } + }, + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-credentials-workload-identity@my-project.iam.gserviceaccount.com:generateAccessToken", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "type": "external_account" +} \ No newline at end of file diff --git a/test/data/workload-identity-token b/test/data/workload-identity-token new file mode 100644 index 0000000..765a8ef --- /dev/null +++ b/test/data/workload-identity-token @@ -0,0 +1 @@ +workload-identity-token \ No newline at end of file diff --git a/test/goth/config_test.exs b/test/goth/config_test.exs index 323b712..3b8b1b1 100644 --- a/test/goth/config_test.exs +++ b/test/goth/config_test.exs @@ -182,6 +182,40 @@ defmodule Goth.ConfigTest do Application.start(:goth) end + test "GOOGLE_APPLICATION_CREDENTIALS is read when workload identity" do + # The test configuration sets an example JSON blob. We override it briefly + # during this test. + current_json = Application.get_env(:goth, :json) + Application.put_env(:goth, :json, nil, persistent: true) + Application.put_env(:goth, :project_id, "my-project") + System.put_env("GOOGLE_APPLICATION_CREDENTIALS", "test/data/test-credentials-workload-identity.json") + Application.stop(:goth) + + Application.start(:goth) + + state = + "test/data/test-credentials-workload-identity.json" + |> Path.expand() + |> File.read!() + |> Jason.decode!() + |> Config.map_config() + + Enum.each(state, fn {_, config} -> + Enum.each(config, fn {key, _} -> + assert {:ok, config[key]} == Config.get(key) + end) + end) + + assert {:ok, :workload_identity} == Config.get(:token_source) + + # Restore original config + Application.put_env(:goth, :json, current_json, persistent: true) + Application.put_env(:goth, :project_id, nil) + System.delete_env("GOOGLE_APPLICATION_CREDENTIALS") + Application.stop(:goth) + Application.start(:goth) + end + test "multiple credentials are parsed correctly" do # The test configuration sets an example JSON blob. We override it briefly # during this test. diff --git a/test/goth/token_test.exs b/test/goth/token_test.exs index 2cf9fdf..9df242a 100644 --- a/test/goth/token_test.exs +++ b/test/goth/token_test.exs @@ -191,6 +191,43 @@ defmodule Goth.TokenTest do assert token.scope == nil end + test "fetch/1 from workload identity" do + token_bypass = Bypass.open() + sa_token_bypass = Bypass.open() + + Bypass.expect(token_bypass, fn conn -> + assert conn.request_path == "/v1/token" + + body = ~s|{"access_token":"dummy","expires_in":3599,"token_type":"Bearer"}| + Plug.Conn.resp(conn, 200, body) + end) + + Bypass.expect(sa_token_bypass, fn conn -> + assert conn.request_path == + "/v1/projects/-/serviceAccounts/test-credentials-workload-identity@my-project.iam.gserviceaccount.com:generateAccessToken" + + body = ~s|{"accessToken":"dummy_sa","expireTime":"2024-06-30T00:00:00Z"}| + Plug.Conn.resp(conn, 200, body) + end) + + credentials = + File.read!("test/data/test-credentials-workload-identity.json") + |> Jason.decode!() + |> Map.put("token_url", "http://localhost:#{token_bypass.port}/v1/token") + |> Map.put( + "service_account_impersonation_url", + "http://localhost:#{sa_token_bypass.port}/v1/projects/-/serviceAccounts/test-credentials-workload-identity@my-project.iam.gserviceaccount.com:generateAccessToken" + ) + + config = %{ + source: {:workload_identity, credentials} + } + + {:ok, token} = Goth.Token.fetch(config) + assert token.token == "dummy_sa" + assert token.scope == nil + end + defp random_service_account_credentials do %{ "private_key" => random_private_key(),