diff --git a/.gitignore b/.gitignore index 98068ee..ee1cb9a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ erl_crash.dump *.ez /config/*credentials* .elixir_ls/ +.DS_Store diff --git a/lib/goth/aws.ex b/lib/goth/aws.ex new file mode 100644 index 0000000..3aa0dab --- /dev/null +++ b/lib/goth/aws.ex @@ -0,0 +1,98 @@ +defmodule Goth.AWS do + @moduledoc """ + Utility functions for interacting with GCP Workload Federation with AWS IAM + as the identity provider. + """ + + @spec aws_iam_subject_token(String.t(), String.t(), String.t(), String.t(), map()) :: + {:ok, String.t()} | {:error, Exception.t()} + def aws_iam_subject_token(url, region_url, regional_cred_url_template, audience, config) do + with {:ok, + %{ + "AccessKeyId" => access_key_id, + "SecretAccessKey" => secret_access_key, + "Token" => token + }} <- credentials_from_metadata(url, config), + {:ok, region} <- region(region_url, config) do + # template the URL + url = String.replace(regional_cred_url_template, "{region}", region) + + # create our AWS client + aws_conf = + ExAws.Config.new(:sts, + access_key_id: access_key_id, + secret_access_key: secret_access_key, + security_token: token, + region: region + ) + + # sign the headers. note that the x-amz-content-hash header must not be + # included due to this GCP bug: https://issuetracker.google.com/issues/190809963 + {:ok, sig_headers} = + ExAws.Auth.headers(:post, url, :sts, aws_conf, [{"x-goog-cloud-target-resource", audience}], "") + + # return the signed request to GCP + request = %{ + "url" => url, + "method" => "POST", + "headers" => for({key, value} <- sig_headers, do: %{"key" => key, "value" => value}) + } + + # this token must be URI encoded twice. once here, and once in the form body. + Jason.encode!(request) + # slashes (ASCII 47) must be left untouched + |> URI.encode(fn char -> char == 47 or URI.char_unreserved?(char) end) + |> then(&{:ok, &1}) + end + end + + defp credentials_from_metadata(url, config) do + # add a trailing slash if missing + url = + case String.ends_with?(url, "/") do + true -> url + false -> "#{url}/" + end + + with {:ok, %{status: 200, body: instance_creds_path}} <- + request(config.http_client, method: :get, url: url, headers: [], body: ""), + # request the instance credentials + {:ok, + %{ + status: 200, + body: instance_creds_body + }} <- request(config.http_client, method: :get, url: "#{url}#{instance_creds_path}", headers: [], body: "") do + Jason.decode(instance_creds_body) + end + end + + defp region(region_url, config) do + # check the ENV var + env_region = System.get_env("AWS_REGION") + + case env_region do + nil -> + # fetch the AZ from the AWS metadata API + {:ok, %{status: 200, body: az}} = + request(config.http_client, method: :get, url: region_url, headers: [], body: "") + + # trim the last character of the az off to get the region + {:ok, String.slice(az, 0..-2//1)} + + env_region -> + {:ok, env_region} + end + end + + defp request({:finch, extra_options}, options) do + Goth.__finch__(options ++ extra_options) + end + + defp request({mod, _} = config, options) when is_atom(mod) do + Goth.HTTPClient.request(config, options[:method], options[:url], options[:headers], options[:body], []) + end + + defp request({fun, extra_options}, options) when is_function(fun, 1) do + fun.(options ++ extra_options) + end +end diff --git a/lib/goth/token.ex b/lib/goth/token.ex index 5356ee5..788bae2 100644 --- a/lib/goth/token.ex +++ b/lib/goth/token.ex @@ -122,6 +122,10 @@ defmodule Goth.Token do #### Workload identity - `{:workload_identity, credentials}` + Same as `{:workload_identity, credentials, []}` + + #### Workload identity - `{:workload_identity, credentials, options}` + The `credentials` is a map and can contain the following keys: * `"token_url"` @@ -139,6 +143,10 @@ defmodule Goth.Token do * `"headers"` - any headers to pass to the url + The `options` is a keywords list and can contain the following keys: + + * `:scopes` - the list of token scopes, defaults to `#{inspect(@default_scopes)}` + #### Google metadata server - `:metadata` Same as `{:metadata, []}` @@ -154,6 +162,12 @@ defmodule Goth.Token do * `:audience` - the audience you want an identity token for, default to `nil` If this parameter is provided, an identity token is returned instead of an access token + ## A note about Workload Identity Federation + + Some external identity providers may require custom support to function correctly. Aside from + the default `"file"` and `"url"` support, Goth currently includes support for AWS + via `Goth.AWS`. + ## Custom HTTP Client To use a custom HTTP client, define a function that receives a keyword list with fields @@ -386,19 +400,30 @@ defmodule Goth.Token do headers = [{"Content-Type", "application/x-www-form-urlencoded"}] + scope = + options + |> Keyword.get(:scopes, @default_scopes) + |> Enum.join(" ") + 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" => List.first(@default_scopes), + "scope" => scope, "subject_token_type" => subject_token_type, - "subject_token" => subject_token_from_credential_source(credential_source, config) + "subject_token" => subject_token_from_credential_source(credential_source, audience, config) }) response = request(config.http_client, method: :post, url: token_url, headers: headers, body: body) - handle_workload_identity_response(response, config) + case handle_workload_identity_response(response, config) do + {:ok, token} -> + {:ok, %{token | scope: scope}} + + {:error, error} -> + {:error, error} + end end defp metadata_options(options) do @@ -416,19 +441,38 @@ defmodule Goth.Token do {url, audience} end - defp subject_token_from_credential_source(%{"url" => url, "headers" => headers, "format" => format}, config) do + defp subject_token_from_credential_source( + %{"url" => url, "headers" => headers, "format" => format}, + _audience, + 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 + defp subject_token_from_credential_source( + %{ + "url" => url, + "environment_id" => "aws1", + "region_url" => region_url, + "regional_cred_verification_url" => regional_cred_url_template + }, + audience, + config + ) do + with {:ok, token} <- Goth.AWS.aws_iam_subject_token(url, region_url, regional_cred_url_template, audience, config) do + token + end + end + + defp subject_token_from_credential_source(%{"file" => file, "format" => format}, _audience, _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}, _config) do + defp subject_token_from_credential_source(%{"file" => file}, _audience, _config) do File.read!(file) end diff --git a/mix.exs b/mix.exs index 9c6ad68..93e5d48 100644 --- a/mix.exs +++ b/mix.exs @@ -43,6 +43,7 @@ defmodule Goth.Mixfile do {:jason, "~> 1.1"}, {:finch, "~> 0.17"}, {:bypass, "~> 2.1", only: :test}, + {:ex_aws, "~> 2.1"}, {: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..b683223 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,7 @@ "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.8", "0393cfbc5e4a9e7017845451a015d836a670397100aa4c86901980e2a2c5f7d4", [:mix], [{:configparser_ex, "~> 4.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.3", [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", "8f79777b7932168956c8cc3a6db41f5783aa816eb50de356aed3165a71e5f8c3"}, "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"}, diff --git a/test/data/test-credentials-aws-workload-identity.json b/test/data/test-credentials-aws-workload-identity.json new file mode 100644 index 0000000..6597722 --- /dev/null +++ b/test/data/test-credentials-aws-workload-identity.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "universe_domain": "googleapis.com", + "audience": "//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/my-pool/providers/my-provider", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "environment_id": "aws1", + "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone", + "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials", + "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + } +} diff --git a/test/goth/token_test.exs b/test/goth/token_test.exs index 4aee720..08cd08f 100644 --- a/test/goth/token_test.exs +++ b/test/goth/token_test.exs @@ -1,23 +1,24 @@ defmodule Goth.TokenTest do use ExUnit.Case, async: true + @default_scope "https://www.googleapis.com/auth/cloud-platform" + test "fetch/1 with service account" do bypass = Bypass.open() - default_scope = "https://www.googleapis.com/auth/cloud-platform" Bypass.expect(bypass, fn conn -> assert %{ "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion" => assertion - } = featch_request_body(conn) + } = fetch_request_body(conn) assert %{ "aud" => "https://www.googleapis.com/oauth2/v4/token", "iss" => "alice@example.com", - "scope" => ^default_scope + "scope" => @default_scope } = jwt_decode(assertion) - body = ~s|{"access_token":"dummy","scope":"#{default_scope}","expires_in":3599,"token_type":"Bearer"}| + body = ~s|{"access_token":"dummy","scope":"#{@default_scope}","expires_in":3599,"token_type":"Bearer"}| Plug.Conn.resp(conn, 200, body) end) @@ -28,39 +29,38 @@ defmodule Goth.TokenTest do {:ok, token} = Goth.Token.fetch(config) assert token.token == "dummy" - assert token.scope == default_scope + assert token.scope == @default_scope assert token.sub == nil end test "fetch/1 with service account and impersonating user" do bypass = Bypass.open() - default_scope = "https://www.googleapis.com/auth/cloud-platform" Bypass.expect(bypass, fn conn -> assert %{ "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion" => assertion - } = featch_request_body(conn) + } = fetch_request_body(conn) assert %{ "iss" => "alice@example.com", - "scope" => ^default_scope, + "scope" => @default_scope, "sub" => "bob@example.com" } = jwt_decode(assertion) - body = ~s|{"access_token":"dummy","scope":"#{default_scope}","expires_in":3599,"token_type":"Bearer"}| + body = ~s|{"access_token":"dummy","scope":"#{@default_scope}","expires_in":3599,"token_type":"Bearer"}| Plug.Conn.resp(conn, 200, body) end) creds = random_service_account_credentials() bypass_url = "http://localhost:#{bypass.port}" - claims = %{"sub" => "bob@example.com", "scope" => default_scope} + claims = %{"sub" => "bob@example.com", "scope" => @default_scope} service_account_source = {:service_account, creds, url: bypass_url, claims: claims} {:ok, token} = Goth.Token.fetch(%{source: service_account_source}) assert token.token == "dummy" - assert token.scope == default_scope + assert token.scope == @default_scope assert token.sub == "bob@example.com" end @@ -71,7 +71,7 @@ defmodule Goth.TokenTest do assert %{ "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion" => assertion - } = featch_request_body(conn) + } = fetch_request_body(conn) assert %{"scope" => "aaa bbb"} = jwt_decode(assertion) @@ -225,7 +225,49 @@ defmodule Goth.TokenTest do {:ok, token} = Goth.Token.fetch(config) assert token.token == "dummy_sa" - assert token.scope == nil + assert token.scope == @default_scope + end + + test "fetch/1 from workload identity and multiple scopes" do + token_bypass = Bypass.open() + sa_token_bypass = Bypass.open() + + Bypass.expect(token_bypass, fn conn -> + assert conn.request_path == "/v1/token" + + assert %{ + "grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", + "scope" => "aaa bbb" + } = fetch_request_body(conn) + + 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, scopes: ["aaa", "bbb"]} + } + + {:ok, token} = Goth.Token.fetch(config) + assert token.token == "dummy_sa" + assert token.scope == "aaa bbb" end test "fetch/1 from direct workload identity" do @@ -249,7 +291,7 @@ defmodule Goth.TokenTest do {:ok, token} = Goth.Token.fetch(config) assert token.token == "dummy" - assert token.scope == nil + assert token.scope == @default_scope end test "fetch/1 from direct workload identity, json format" do @@ -273,7 +315,7 @@ defmodule Goth.TokenTest do {:ok, token} = Goth.Token.fetch(config) assert token.token == "dummy" - assert token.scope == nil + assert token.scope == @default_scope end test "fetch/1 from url-based workload identity" do @@ -308,7 +350,112 @@ defmodule Goth.TokenTest do {:ok, token} = Goth.Token.fetch(config) assert token.token == "dummy" - assert token.scope == nil + assert token.scope == @default_scope + end + + test "fetch/1 with AWS workload identity" do + access_key_id = "ASIAXXXXXXXXXXXXXXXXXX" + secret_access_key = "oXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + token = "oXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + metadata_region_bypass = Bypass.open() + metadata_credentials_bypass = Bypass.open() + sts_bypass = Bypass.open() + token_bypass = Bypass.open() + + Bypass.expect(metadata_region_bypass, fn conn -> + assert conn.request_path == "/latest/meta-data/placement/availability-zone" + Plug.Conn.resp(conn, 200, "us-east-1a") + end) + + Bypass.expect(metadata_credentials_bypass, fn conn -> + assert conn.request_path == "/latest/meta-data/iam/security-credentials/" + Plug.Conn.resp(conn, 200, "test-role") + end) + + Bypass.expect(metadata_credentials_bypass, "GET", "/latest/meta-data/iam/security-credentials/test-role", fn conn -> + body = ~s| + { + "Code": "Success", + "LastUpdated": "2023-10-26T21:31:48Z", + "Type": "AWS-HMAC", + "AccessKeyId": "#{access_key_id}", + "SecretAccessKey": "#{secret_access_key}", + "Token": "#{token}", + "Expiration": "2023-10-27T00:01:01Z" + } + | + Plug.Conn.resp(conn, 200, body) + end) + + Bypass.expect(token_bypass, fn conn -> + assert conn.request_path == "/v1/token" + + req_body = conn |> Plug.Conn.read_body() |> elem(1) + params = URI.decode_query(req_body) + subject_token = params["subject_token"] + audience = params["audience"] + + aws_conf = + ExAws.Config.new(:sts, + access_key_id: access_key_id, + secret_access_key: secret_access_key, + security_token: token, + region: "us-east-1" + ) + + regional_cred_verification_url = + "http://localhost:#{sts_bypass.port}/?Action=GetCallerIdentity&Version=2011-06-15" + + {:ok, expected_headers} = + ExAws.Auth.headers( + :post, + regional_cred_verification_url, + :sts, + aws_conf, + [{"x-goog-cloud-target-resource", audience}], + "" + ) + + decoded_request = URI.decode(subject_token) |> Jason.decode!() + request_headers = for %{"key" => k, "value" => v} <- decoded_request["headers"], into: %{}, do: {k, v} + + Enum.each(expected_headers, fn {key, value} -> + assert Map.get(request_headers, key) == value + end) + + body = ~s|{"access_token":"dummy","expires_in":3599,"token_type":"Bearer"}| + + Plug.Conn.resp(conn, 200, body) + end) + + credentials = + File.read!("test/data/test-credentials-aws-workload-identity.json") + |> Jason.decode!() + |> Map.put("token_url", "http://localhost:#{token_bypass.port}/v1/token") + |> Map.update!("credential_source", fn source -> + source + |> Map.put( + "region_url", + "http://localhost:#{metadata_region_bypass.port}/latest/meta-data/placement/availability-zone" + ) + |> Map.put( + "url", + "http://localhost:#{metadata_credentials_bypass.port}/latest/meta-data/iam/security-credentials" + ) + |> Map.put( + "regional_cred_verification_url", + "http://localhost:#{sts_bypass.port}/?Action=GetCallerIdentity&Version=2011-06-15" + ) + end) + + config = %{ + source: {:workload_identity, credentials} + } + + {:ok, token} = Goth.Token.fetch(config) + assert token.token == "dummy" + assert token.scope == @default_scope end defp random_service_account_credentials do @@ -325,7 +472,7 @@ defmodule Goth.TokenTest do :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)]) end - defp featch_request_body(%Plug.Conn{} = conn) do + defp fetch_request_body(%Plug.Conn{} = conn) do assert {:ok, req_body, _} = Plug.Conn.read_body(conn) URI.decode_query(req_body) end