From 0226efab32541d0482f6f745d5e7d515ed45a09a Mon Sep 17 00:00:00 2001 From: Gwendolyn Van Hove Date: Fri, 20 Dec 2024 02:29:01 -0800 Subject: [PATCH] Support fetching subject token from url for workload identity (#179) --- lib/goth/token.ex | 47 ++++++++++++++----- ...est-credentials-url-workload-identity.json | 17 +++++++ test/goth/token_test.exs | 35 ++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 test/data/test-credentials-url-workload-identity.json diff --git a/lib/goth/token.ex b/lib/goth/token.ex index 01171cd..728ad4b 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -38,6 +38,8 @@ defmodule Goth.Token do * `{:refresh_token, credentials}` - for fetching token using refresh token + * `{:workload_identity, credentials}` - for fetching token using workload identity + * `:metadata` - for fetching token using Google internal metadata service If `:source` is not set, Goth will: @@ -118,6 +120,25 @@ defmodule Goth.Token do * `:url` - the URL of the authentication service, defaults to: `"https://www.googleapis.com/oauth2/v4/token"` + #### Workload identity - `{:workload_identity, credentials}` + + The `credentials` is a map and can contain the following keys: + + * `"token_url"` + + * `"audience"` + + * `"subject_token_type"` + + * `"credential_source"` - information about how to retrieve the subject token, can contain the following keys: + * `"format"` - including a `"type"` of `"json"` or `"text"` and optionally `"subject_token_field_name"` if `"json"` + + * `"file"` - file to read the subject token from + + * `"url"` - url to fetch the subject token from + + * `"headers"` - any headers to pass to the url + #### Google metadata server - `:metadata` Same as `{:metadata, []}` @@ -367,7 +388,7 @@ defmodule Goth.Token do "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) + "subject_token" => subject_token_from_credential_source(credential_source, config) }) response = request(config.http_client, method: :post, url: token_url, headers: headers, body: body) @@ -390,23 +411,27 @@ defmodule Goth.Token do {url, audience} end - defp subject_token_from_credential_source(%{"file" => file, "format" => format}) do - binary = File.read!(file) - - case format do - %{"type" => "text"} -> - binary - - %{"type" => "json", "subject_token_field_name" => field} -> - binary |> Jason.decode!() |> Map.fetch!(field) + defp subject_token_from_credential_source(%{"url" => url, "headers" => headers, "format" => format}, config) do + with {:ok, %{status: 200, body: body}} <- + request(config.http_client, method: :get, url: url, headers: Enum.to_list(headers), body: "") do + subject_token_from_binary(body, format) end end + defp subject_token_from_credential_source(%{"file" => file, "format" => format}, _config) do + File.read!(file) |> subject_token_from_binary(format) + end + # the default file type if not specified is "text" - defp subject_token_from_credential_source(%{"file" => file}) do + defp subject_token_from_credential_source(%{"file" => file}, _config) do File.read!(file) end + defp subject_token_from_binary(binary, %{"type" => "text"}), do: binary + + defp subject_token_from_binary(binary, %{"type" => "json", "subject_token_field_name" => field}), + do: binary |> Jason.decode!() |> Map.fetch!(field) + defp handle_jwt_response({:ok, %{status: 200, body: body}}) do {:ok, build_token(%{"id_token" => body})} end diff --git a/test/data/test-credentials-url-workload-identity.json b/test/data/test-credentials-url-workload-identity.json new file mode 100644 index 0000000..9a0046a --- /dev/null +++ b/test/data/test-credentials-url-workload-identity.json @@ -0,0 +1,17 @@ +{ + "audience": "//iam.googleapis.com/projects/my-project/locations/global/workloadIdentityPools/my-cluster/providers/my-provider", + "credential_source": { + "url": "", + "headers": { + "Authentication": "Bearer 123", + "X-Other": "things" + }, + "format": { + "type": "json", + "subject_token_field_name": "token" + } + }, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "type": "external_account" +} diff --git a/test/goth/token_test.exs b/test/goth/token_test.exs index 76601a9..4aee720 100644 --- a/test/goth/token_test.exs +++ b/test/goth/token_test.exs @@ -276,6 +276,41 @@ defmodule Goth.TokenTest do assert token.scope == nil end + test "fetch/1 from url-based workload identity" do + token_bypass = Bypass.open() + subject_token_bypass = Bypass.open() + + Bypass.expect(subject_token_bypass, fn conn -> + assert conn.request_path == "/get/credentials" + + body = File.read!("test/data/workload-identity-token.json") + Plug.Conn.resp(conn, 200, body) + end) + + Bypass.expect(token_bypass, fn conn -> + assert conn.request_path == "/v1/token" + + body = ~s|{"access_token":"dummy","expires_in":599,"token_type":"Bearer"}| + Plug.Conn.resp(conn, 200, body) + end) + + credentials = + File.read!("test/data/test-credentials-url-workload-identity.json") + |> Jason.decode!() + |> Map.put("token_url", "http://localhost:#{token_bypass.port}/v1/token") + |> Map.update!("credential_source", fn source -> + Map.put(source, "url", "http://localhost:#{subject_token_bypass.port}/get/credentials") + end) + + config = %{ + source: {:workload_identity, credentials} + } + + {:ok, token} = Goth.Token.fetch(config) + assert token.token == "dummy" + assert token.scope == nil + end + defp random_service_account_credentials do %{ "private_key" => random_private_key(),