Skip to content

Commit

Permalink
support workload identity (#170)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelst authored Apr 8, 2024
1 parent 4b88576 commit 2eeff14
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 0 deletions.
4 changes: 4 additions & 0 deletions lib/goth/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions lib/goth/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/data/test-credentials-workload-identity.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions test/data/workload-identity-token
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
workload-identity-token
34 changes: 34 additions & 0 deletions test/goth/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions test/goth/token_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down

0 comments on commit 2eeff14

Please sign in to comment.