From 8aed94c739eff3058b4a26cc5ef78161d891dc71 Mon Sep 17 00:00:00 2001 From: Ygor Castor Date: Tue, 9 Sep 2025 13:12:48 +0200 Subject: [PATCH 1/4] Adds AWS Federated Workspace Identity --- lib/goth/config.ex | 11 ++- lib/goth/token.ex | 23 +++++- lib/goth/token/ex_aws.ex | 122 ++++++++++++++++++++++++++++++++ mix.exs | 4 ++ mix.lock | 12 ++++ test/goth/token/ex_aws_test.exs | 50 +++++++++++++ test/goth/token_test.exs | 1 + test/test_helper.exs | 5 ++ 8 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 lib/goth/token/ex_aws.ex create mode 100644 test/goth/token/ex_aws_test.exs diff --git a/lib/goth/config.ex b/lib/goth/config.ex index 82439dc..3a2e657 100644 --- a/lib/goth/config.ex +++ b/lib/goth/config.ex @@ -305,7 +305,16 @@ defmodule Goth.Config do end defp set_token_source(%{"type" => "external_account"} = map) do - Map.put(map, "token_source", :workload_identity) + # Check if this is an AWS workload identity configuration + is_aws = case map do + %{"subject_token_type" => "urn:x-oauth:params:oauth:token-type:aws"} -> true + %{"credential_source" => %{"regional_cred_verification_url" => _}} -> true + _ -> false + end + + map + |> Map.put("token_source", :workload_identity) + |> then(fn m -> if is_aws, do: Map.put(m, "provider", "aws"), else: m end) end defp set_token_source(list) when is_list(list) do diff --git a/lib/goth/token.ex b/lib/goth/token.ex index 5356ee5..a781db6 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -386,6 +386,19 @@ defmodule Goth.Token do headers = [{"Content-Type", "application/x-www-form-urlencoded"}] + subject_token = + if is_aws_workload_identity?(credentials) do + case Goth.Token.ExAws.generate_subject_token() do + {:ok, token} -> + token + + {:error, reason} -> + raise "Failed to generate AWS subject token: #{reason}" + end + else + subject_token_from_credential_source(credential_source, config) + end + body = URI.encode_query(%{ "audience" => audience, @@ -393,7 +406,7 @@ defmodule Goth.Token do "requested_token_type" => "urn:ietf:params:oauth:token-type:access_token", "scope" => List.first(@default_scopes), "subject_token_type" => subject_token_type, - "subject_token" => subject_token_from_credential_source(credential_source, config) + "subject_token" => subject_token }) response = request(config.http_client, method: :post, url: token_url, headers: headers, body: body) @@ -416,6 +429,14 @@ defmodule Goth.Token do {url, audience} end + defp is_aws_workload_identity?(credentials) do + case credentials do + %{"subject_token_type" => "urn:x-oauth:params:oauth:token-type:aws"} -> true + %{"credential_source" => %{"regional_cred_verification_url" => _}} -> true + _ -> false + end + end + 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 diff --git a/lib/goth/token/ex_aws.ex b/lib/goth/token/ex_aws.ex new file mode 100644 index 0000000..7f0f551 --- /dev/null +++ b/lib/goth/token/ex_aws.ex @@ -0,0 +1,122 @@ +defmodule Goth.Token.ExAws do + @moduledoc """ + AWS workload identity integration using ex_aws/ex_aws_sts. + + This module provides AWS workload identity federation support by leveraging + the existing ex_aws and ex_aws_sts libraries for credential resolution and + AWS API integration. + + ## Dependencies + + This functionality requires the following optional dependencies: + - `ex_aws ~> 2.1` + - `ex_aws_sts ~> 2.0` + + If these dependencies are not available, AWS workload identity federation + will not be supported, but other Goth functionality remains unaffected. + + ## Credential Sources Supported + + Through ex_aws, this module automatically supports: + - EC2 Instance Metadata Service (IMDSv1 and IMDSv2) + - ECS Task Credentials + - AWS Lambda execution role + - Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.) + - AWS CLI config files (~/.aws/credentials, ~/.aws/config) + - IAM roles and assume role chains + """ + + require Logger + + @doc """ + Generates a subject token for AWS workload identity federation. + + This creates a signed GetCallerIdentity request using ex_aws that can be + exchanged with Google's STS for an access token. + + ## Parameters + + - `credential_source` - The credential source configuration from the workload identity config + - `http_client` - HTTP client configuration (currently unused, ex_aws handles its own HTTP) + + ## Returns + + - `{:ok, subject_token}` - Base64-encoded signed request for Google STS + - `{:error, reason}` - Error message, including missing dependencies + """ + + if Code.ensure_loaded?(ExAws) && Code.ensure_loaded?(ExAws.STS) do + def generate_subject_token() do + with {:ok, signed_request} <- build_signed_request() do + {:ok, encode_for_google_sts(signed_request)} + end + end + + defp build_signed_request() do + params = %{ + "Version" => "2011-06-15", + "Action" => "GetCallerIdentity" + } + + operation = %ExAws.Operation.Query{ + path: "/", + params: params, + service: :sts, + action: :get_caller_identity + } + + case ExAws.request(operation, http_client: Goth.Token.ExAws.RequestCapture) do + {:ok, %{body: request_details}} -> + {:ok, request_details} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, "Failed to build signed request"} + end + end + + defmodule RequestCapture do + @behaviour ExAws.Request.HttpClient + + @impl ExAws.Request.HttpClient + def request(method, url, body, headers, _http_opts) do + request_details = %{ + method: method |> Atom.to_string() |> String.upcase(), + url: url, + headers: headers, + body: body || "" + } + + response = %{ + status_code: 200, + headers: headers, + body: request_details + } + + {:ok, response} + end + end + + defp encode_for_google_sts(signed_request) do + token_data = %{ + "url" => signed_request.url, + "method" => signed_request.method, + "headers" => + Enum.map(signed_request.headers, fn {key, value} -> + %{"key" => key, "value" => value} + end), + "body" => Base.encode64(signed_request.body) + } + + token_data + |> Jason.encode!() + |> Base.url_encode64(padding: false) + end + else + def generate_subject_token(_credential_source, _http_client) do + {:error, "ex_aws and ex_aws_sts dependencies are required for AWS workload identity federation"} + end + end +end diff --git a/mix.exs b/mix.exs index 9c6ad68..3683eed 100644 --- a/mix.exs +++ b/mix.exs @@ -42,7 +42,11 @@ defmodule Goth.Mixfile do {:jose, "~> 1.11"}, {:jason, "~> 1.1"}, {:finch, "~> 0.17"}, + {:ex_aws, "~> 2.1", optional: true}, + {:ex_aws_sts, "~> 2.0", optional: true}, {:bypass, "~> 2.1", only: :test}, + {:mimic, "~> 2.1", only: :test}, + {:hackney, "~> 1.9", only: :test}, {:ex_doc, "~> 0.19", only: :dev}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 0.5", only: [:dev], runtime: false} diff --git a/mix.lock b/mix.lock index d170251..56dea73 100644 --- a/mix.lock +++ b/mix.lock @@ -1,29 +1,41 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm", "6c32a70ed5d452c6650916555b1f96c79af5fc4bf286997f8b15f213de786f73"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "ex_aws": {:hex, :ex_aws, "2.5.11", "5646eaad701485505b78246b0cd406fde9b1619459a86e85b53398810d3d0bd3", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e16100ff93a118ef01c916d945969535cbe8d4ab6593fcf01d1cf854eb75345"}, + "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"}, "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mimic": {:hex, :mimic, "2.1.0", "60f0261b3dcd7f70a4e1c06e455d9e703718bf4fca3493bde076485ce8330992", [:mix], [{:ham, "~> 0.3", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "9d61c2a70933ed04506208adaf23025c824e8e9dfdd1fea884afffb24b6f0956"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/goth/token/ex_aws_test.exs b/test/goth/token/ex_aws_test.exs new file mode 100644 index 0000000..2e46659 --- /dev/null +++ b/test/goth/token/ex_aws_test.exs @@ -0,0 +1,50 @@ +defmodule Goth.Token.ExAwsTest do + use ExUnit.Case, async: true + + @moduletag :integration + + alias Goth.Token.ExAws + + describe "generate_subject_token/0" do + test "generates a properly formatted subject token" do + case ExAws.generate_subject_token() do + {:ok, token} -> + assert is_binary(token) + decoded_token = Base.url_decode64!(token, padding: false) + token_data = Jason.decode!(decoded_token) + + assert Map.has_key?(token_data, "url") + assert Map.has_key?(token_data, "method") + assert Map.has_key?(token_data, "headers") + assert Map.has_key?(token_data, "body") + + assert token_data["method"] == "POST" + + url = token_data["url"] + assert String.contains?(url, "sts") + assert String.contains?(url, "GetCallerIdentity") + + headers = token_data["headers"] + assert is_list(headers) + + Enum.each(headers, fn header -> + assert Map.has_key?(header, "key") + assert Map.has_key?(header, "value") + end) + + auth_header = Enum.find(headers, fn h -> h["key"] == "authorization" end) + assert auth_header != nil + assert String.contains?(auth_header["value"], "AWS4-HMAC-SHA256") + + assert is_binary(token_data["body"]) + + {:error, reason} when is_binary(reason) -> + flunk("Expected success but got error: #{reason}") + + other -> + flunk("Unexpected return value: #{inspect(other)}") + end + end + end +end + diff --git a/test/goth/token_test.exs b/test/goth/token_test.exs index 4aee720..fcc1399 100644 --- a/test/goth/token_test.exs +++ b/test/goth/token_test.exs @@ -1,5 +1,6 @@ defmodule Goth.TokenTest do use ExUnit.Case, async: true + use Mimic test "fetch/1 with service account" do bypass = Bypass.open() diff --git a/test/test_helper.exs b/test/test_helper.exs index 81a8db3..8f2535b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,8 @@ {:ok, _} = Application.ensure_all_started(:bypass) +# Set up Mimic for mocking +Mimic.copy(ExAws.Config) +Mimic.copy(ExAws.STS) +Mimic.copy(ExAws.Auth) + ExUnit.start(exclude: [:integration]) From f16e94ac205f2334ea7441892925118b7c20e923 Mon Sep 17 00:00:00 2001 From: Ygor Castor Date: Wed, 10 Sep 2025 14:08:08 +0200 Subject: [PATCH 2/4] WIP --- lib/goth/token.ex | 2 +- lib/goth/token/ex_aws.ex | 94 ++++++++++++++++------------------------ 2 files changed, 38 insertions(+), 58 deletions(-) diff --git a/lib/goth/token.ex b/lib/goth/token.ex index a781db6..677c146 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -388,7 +388,7 @@ defmodule Goth.Token do subject_token = if is_aws_workload_identity?(credentials) do - case Goth.Token.ExAws.generate_subject_token() do + case Goth.Token.ExAws.generate_subject_token(audience) do {:ok, token} -> token diff --git a/lib/goth/token/ex_aws.ex b/lib/goth/token/ex_aws.ex index 7f0f551..51db718 100644 --- a/lib/goth/token/ex_aws.ex +++ b/lib/goth/token/ex_aws.ex @@ -1,6 +1,6 @@ defmodule Goth.Token.ExAws do @moduledoc """ - AWS workload identity integration using ex_aws/ex_aws_sts. + AWS workload identity integration using ex_aws. This module provides AWS workload identity federation support by leveraging the existing ex_aws and ex_aws_sts libraries for credential resolution and @@ -10,7 +10,6 @@ defmodule Goth.Token.ExAws do This functionality requires the following optional dependencies: - `ex_aws ~> 2.1` - - `ex_aws_sts ~> 2.0` If these dependencies are not available, AWS workload identity federation will not be supported, but other Goth functionality remains unaffected. @@ -36,8 +35,8 @@ defmodule Goth.Token.ExAws do ## Parameters - - `credential_source` - The credential source configuration from the workload identity config - - `http_client` - HTTP client configuration (currently unused, ex_aws handles its own HTTP) + - `audience` - The audience string to include in the signed request, + typically the full resource name of the Google service account. ## Returns @@ -46,77 +45,58 @@ defmodule Goth.Token.ExAws do """ if Code.ensure_loaded?(ExAws) && Code.ensure_loaded?(ExAws.STS) do - def generate_subject_token() do - with {:ok, signed_request} <- build_signed_request() do + def generate_subject_token(audience) do + with {:ok, signed_request} <- build_signed_request(audience) do {:ok, encode_for_google_sts(signed_request)} end end - defp build_signed_request() do - params = %{ - "Version" => "2011-06-15", - "Action" => "GetCallerIdentity" - } + defp build_signed_request(audience) do + # Build the STS GetCallerIdentity request + url = "https://sts.amazonaws.com/" + params = "Action=GetCallerIdentity&Version=2011-06-15" + url = URI.parse(url) |> URI.append_query(params) |> URI.to_string() - operation = %ExAws.Operation.Query{ - path: "/", - params: params, - service: :sts, - action: :get_caller_identity - } + headers = [{"x-goog-cloud-target-resource", audience}] + + config = ExAws.Config.new(:sts) + + case ExAws.Auth.headers(:post, url, :sts, config, headers, "") do + {:ok, signed_headers} -> + request_details = + %{ + "method" => "POST", + "url" => url, + "headers" => Map.new(signed_headers) + } - case ExAws.request(operation, http_client: Goth.Token.ExAws.RequestCapture) do - {:ok, %{body: request_details}} -> {:ok, request_details} {:error, reason} -> {:error, reason} - - _ -> - {:error, "Failed to build signed request"} - end - end - - defmodule RequestCapture do - @behaviour ExAws.Request.HttpClient - - @impl ExAws.Request.HttpClient - def request(method, url, body, headers, _http_opts) do - request_details = %{ - method: method |> Atom.to_string() |> String.upcase(), - url: url, - headers: headers, - body: body || "" - } - - response = %{ - status_code: 200, - headers: headers, - body: request_details - } - - {:ok, response} end end defp encode_for_google_sts(signed_request) do - token_data = %{ - "url" => signed_request.url, - "method" => signed_request.method, - "headers" => - Enum.map(signed_request.headers, fn {key, value} -> - %{"key" => key, "value" => value} - end), - "body" => Base.encode64(signed_request.body) + headers = + Enum.map(signed_request["headers"], fn {k, v} -> + %{ + "key" => k, + "value" => v + } + end) + + %{ + "url" => signed_request["url"], + "method" => signed_request["method"], + "headers" => headers } - - token_data |> Jason.encode!() - |> Base.url_encode64(padding: false) + |> URI.encode() end else - def generate_subject_token(_credential_source, _http_client) do - {:error, "ex_aws and ex_aws_sts dependencies are required for AWS workload identity federation"} + def generate_subject_token(_audience) do + {:error, "ex_aws dependency is required for AWS workload identity federation"} end end end From aee37842252f41aa697a81873fb411b8b1646345 Mon Sep 17 00:00:00 2001 From: Ygor Castor Date: Fri, 19 Sep 2025 14:23:39 +0200 Subject: [PATCH 3/4] URI.encode is messing up the token, the erlang one works --- lib/goth/token/ex_aws.ex | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/goth/token/ex_aws.ex b/lib/goth/token/ex_aws.ex index 51db718..9c2e18a 100644 --- a/lib/goth/token/ex_aws.ex +++ b/lib/goth/token/ex_aws.ex @@ -52,7 +52,6 @@ defmodule Goth.Token.ExAws do end defp build_signed_request(audience) do - # Build the STS GetCallerIdentity request url = "https://sts.amazonaws.com/" params = "Action=GetCallerIdentity&Version=2011-06-15" url = URI.parse(url) |> URI.append_query(params) |> URI.to_string() @@ -78,21 +77,24 @@ defmodule Goth.Token.ExAws do end defp encode_for_google_sts(signed_request) do - headers = - Enum.map(signed_request["headers"], fn {k, v} -> - %{ - "key" => k, - "value" => v - } - end) - - %{ + token = %{ "url" => signed_request["url"], "method" => signed_request["method"], - "headers" => headers + "headers" => + Enum.map(signed_request["headers"], fn {key, value} -> + %{"key" => key, "value" => value} + end) } + + token |> Jason.encode!() - |> URI.encode() + |> url_quote() + end + + defp url_quote(string) do + string + |> :uri_string.quote() + |> to_string() end else def generate_subject_token(_audience) do From 8926358c8e7ce015481054516e1e1fd857698a89 Mon Sep 17 00:00:00 2001 From: Ygor Castor Date: Mon, 24 Nov 2025 13:41:13 +0100 Subject: [PATCH 4/4] Updated credo to fix build on 1.19 --- mix.exs | 2 +- mix.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 3683eed..3cb7816 100644 --- a/mix.exs +++ b/mix.exs @@ -48,7 +48,7 @@ defmodule Goth.Mixfile do {:mimic, "~> 2.1", only: :test}, {:hackney, "~> 1.9", only: :test}, {:ex_doc, "~> 0.19", only: :dev}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 0.5", only: [:dev], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 56dea73..e5135f6 100644 --- a/mix.lock +++ b/mix.lock @@ -5,13 +5,13 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm", "6c32a70ed5d452c6650916555b1f96c79af5fc4bf286997f8b15f213de786f73"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, "ex_aws": {:hex, :ex_aws, "2.5.11", "5646eaad701485505b78246b0cd406fde9b1619459a86e85b53398810d3d0bd3", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e16100ff93a118ef01c916d945969535cbe8d4ab6593fcf01d1cf854eb75345"}, "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, - "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"},