From 1f1ae5a764369710d8fec177be413e9d15450a58 Mon Sep 17 00:00:00 2001 From: Jie Zheng Date: Sun, 21 Sep 2025 16:08:30 +0800 Subject: [PATCH] Support pod identity adapter --- README.md | 31 ++++ config/config.exs | 4 +- .../assume_role_pod_identity_adapter.ex | 52 ++++++ mix.exs | 1 - mix.lock | 13 +- .../assume_role_pod_identity_adapter_test.exs | 165 ++++++++++++++++++ .../assume_role_web_identity_adapter_test.exs | 3 +- 7 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 lib/ex_aws/sts/auth_cache/assume_role_pod_identity_adapter.ex create mode 100644 test/lib/auth_cache/assume_role_pod_identity_adapter_test.exs diff --git a/README.md b/README.md index d28bd06..55f7d8f 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,37 @@ config :ex_aws, awscli_auth_adapter: ExAws.STS.AuthCache.AssumeRoleWebIdentityAdapter ``` +### Using Pod Identity tokens from ENV vars + +For AWS EKS Pod Identity authentication, you can use the Pod Identity adapter that retrieves credentials directly from the container credentials endpoint. It uses the following environment variables: + +`AWS_CONTAINER_CREDENTIALS_FULL_URI`: full URI of the container credentials endpoint +`AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`: path to the file containing the authorization token + +**Note**: If `AWS_CONTAINER_CREDENTIALS_FULL_URI` is not set or is `nil`, the adapter will return an empty map, allowing the authentication to fall back to other methods. + +```elixir +config :ex_aws, + secret_access_key: [{:awscli, "profile_name", 30}], + access_key_id: [{:awscli, "profile_name", 30}], + awscli_auth_adapter: ExAws.STS.AuthCache.AssumeRolePodIdentityAdapter +``` + +You can also inject these values directly via configuration: + +```elixir +config :ex_aws, + access_key_id: [{:awscli, "default", 30}], + secret_access_key: [{:awscli, "default", 30}], + awscli_auth_adapter: ExAws.STS.AuthCache.AssumeRolePodIdentityAdapter, + awscli_credentials: %{ + "default" => %{ + container_credentials_uri: "http://169.254.170.2/v2/credentials/12345678-1234-1234-1234-123456789012", + container_authorization_token_file: "/var/run/secrets/eks.amazonaws.com/serviceaccount/token" + } + } +``` + ## License The MIT License (MIT) diff --git a/config/config.exs b/config/config.exs index b1a1351..522b0e7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,6 @@ # This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. -use Mix.Config +# and its dependencies with the aid of the Config module. +import Config # This configuration is loaded before any dependency and is restricted # to this project. If another project depends on this project, this diff --git a/lib/ex_aws/sts/auth_cache/assume_role_pod_identity_adapter.ex b/lib/ex_aws/sts/auth_cache/assume_role_pod_identity_adapter.ex new file mode 100644 index 0000000..09f3efe --- /dev/null +++ b/lib/ex_aws/sts/auth_cache/assume_role_pod_identity_adapter.ex @@ -0,0 +1,52 @@ +defmodule ExAws.STS.AuthCache.AssumeRolePodIdentityAdapter do + @moduledoc """ + Provides a custom Adapter that intercepts ExAWS configuration + which uses Pod Identity Tokens for authentication. + """ + + @behaviour ExAws.Config.AuthCache.AuthConfigAdapter + + @impl true + def adapt_auth_config(config, _profile, _expiration) do + case container_credentials_full_uri(config) do + nil -> + %{} + + uri -> + http_client = config[:http_client] || ExAws.Request.Hackney + json_codec = config[:json_codec] || Jason + + case http_client.request(:get, uri, "", [ + {"Authorization", container_authorization_token(config)} + ], []) do + {:ok, %{body: body}} -> + values = json_codec.decode!(body) + + %{ + access_key_id: values["AccessKeyId"], + secret_access_key: values["SecretAccessKey"], + security_token: values["Token"], + expiration: values["Expiration"] + } + + {:error, reason} -> + {:error, reason} + end + end + end + + defp container_credentials_full_uri(config) do + config[:container_credentials_uri] || System.get_env("AWS_CONTAINER_CREDENTIALS_FULL_URI") + end + + defp container_authorization_token(config) do + config + |> container_authorization_token_file() + |> File.read!() + end + + defp container_authorization_token_file(config) do + config[:container_authorization_token_file] || + System.get_env("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") + end +end diff --git a/mix.exs b/mix.exs index b76149c..06c1bee 100644 --- a/mix.exs +++ b/mix.exs @@ -47,7 +47,6 @@ defmodule ExAws.STS.Mixfile do defp deps do [ {:mox, ">= 0.0.3", only: :test}, - {:briefly, ">= 0.0.3", only: :test}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:hackney, ">= 0.0.0", only: [:dev, :test]}, {:sweet_xml, ">= 0.0.0", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 1776f10..e20913e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,23 +1,22 @@ %{ - "briefly": {:hex, :briefly, "0.3.0", "16e6b76d2070ebc9cbd025fa85cf5dbaf52368c4bd896fb482b5a6b95a540c2f", [:mix], [], "hexpm", "c6ebf8fc3dcd4950dd10c03e953fb4f553a8bcf0ff4c8c40d71542434cd7e046"}, - "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm", "e3be2bc3ae67781db529b80aa7e7c49904a988596e2dbff897425b48b3581161"}, "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, "ex_aws": {:hex, :ex_aws, "2.2.3", "f9bb1b04aaed3270f08c99d0268ff2c6596edb3a4a8792d18ffd37a74dd4810d", [: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, "~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ddf1da94624e5f92afe36d4146086f72d85bb15998cec137c1fd854a3f04bc4"}, "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, - "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [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.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.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.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, + "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"}, "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.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mox": {:hex, :mox, "0.5.2", "55a0a5ba9ccc671518d068c8dddd20eeb436909ea79d1799e2209df7eaa98b6c", [:mix], [], "hexpm", "df4310628cd628ee181df93f50ddfd07be3e5ecc30232d3b6aadf30bdfe6092b"}, "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/lib/auth_cache/assume_role_pod_identity_adapter_test.exs b/test/lib/auth_cache/assume_role_pod_identity_adapter_test.exs new file mode 100644 index 0000000..18d353a --- /dev/null +++ b/test/lib/auth_cache/assume_role_pod_identity_adapter_test.exs @@ -0,0 +1,165 @@ +defmodule ExAws.STS.AuthCache.AssumeRolePodIdentityAdapterTest do + use ExUnit.Case, async: true + alias ExAws.STS.AuthCache.AssumeRolePodIdentityAdapter + + import Mox + + @container_credentials_uri "http://169.254.170.2/v2/credentials/12345678-1234-1234-1234-123456789012" + @authorization_token "Bearer token123" + @expiration 30_000 + + setup do + token_path = Path.join(System.tmp_dir!(), "test_token_#{System.unique_integer()}") + File.write!(token_path, @authorization_token) + + System.put_env("AWS_CONTAINER_CREDENTIALS_FULL_URI", @container_credentials_uri) + System.put_env("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", token_path) + + on_exit(fn -> + File.rm_rf(token_path) + System.delete_env("AWS_CONTAINER_CREDENTIALS_FULL_URI") + System.delete_env("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") + end) + end + + describe "when the config values are injected" do + test "#adapt_auth_config" do + config = %{ + container_credentials_uri: "http://custom.endpoint/credentials", + container_authorization_token_file: create_token_file("custom_token"), + http_client: ExAws.Request.HttpMock, + json_codec: Jason + } + + response_body = %{ + "AccessKeyId" => "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey" => "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token" => "session_token_example", + "Expiration" => "2023-12-31T23:59:59Z" + } + + ExAws.Request.HttpMock + |> expect(:request, fn :get, + "http://custom.endpoint/credentials", + "", + [{"Authorization", "custom_token"}], + _opts -> + {:ok, %{body: Jason.encode!(response_body)}} + end) + + expected = %{ + access_key_id: "AKIAIOSFODNN7EXAMPLE", + secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + security_token: "session_token_example", + expiration: "2023-12-31T23:59:59Z" + } + + assert expected == AssumeRolePodIdentityAdapter.adapt_auth_config(config, nil, @expiration) + end + end + + describe "when using environment variables" do + test "#adapt_auth_config" do + config = %{ + http_client: ExAws.Request.HttpMock, + json_codec: Jason + } + + response_body = %{ + "AccessKeyId" => "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey" => "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token" => "session_token_example", + "Expiration" => "2023-12-31T23:59:59Z" + } + + ExAws.Request.HttpMock + |> expect(:request, fn :get, + @container_credentials_uri, + "", + [{"Authorization", @authorization_token}], + _opts -> + {:ok, %{body: Jason.encode!(response_body)}} + end) + + expected = %{ + access_key_id: "AKIAIOSFODNN7EXAMPLE", + secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + security_token: "session_token_example", + expiration: "2023-12-31T23:59:59Z" + } + + assert expected == AssumeRolePodIdentityAdapter.adapt_auth_config(config, nil, @expiration) + end + end + + describe "when the HTTP request fails" do + test "#adapt_auth_config returns error" do + config = %{ + http_client: ExAws.Request.HttpMock, + json_codec: Jason + } + + ExAws.Request.HttpMock + |> expect(:request, fn :get, + @container_credentials_uri, + "", + [{"Authorization", @authorization_token}], + _opts -> + {:error, :timeout} + end) + + assert {:error, :timeout} == AssumeRolePodIdentityAdapter.adapt_auth_config(config, nil, @expiration) + end + end + + describe "when the authorization token file does not exist" do + test "#adapt_auth_config raises File.Error" do + System.put_env("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "./does_not_exist") + + config = %{ + http_client: ExAws.Request.HttpMock, + json_codec: Jason + } + + assert_raise File.Error, fn -> + AssumeRolePodIdentityAdapter.adapt_auth_config(config, nil, @expiration) + end + end + end + + describe "when the container credentials URI is not provided" do + test "#adapt_auth_config returns empty map when env var is not set" do + System.delete_env("AWS_CONTAINER_CREDENTIALS_FULL_URI") + + config = %{ + http_client: ExAws.Request.HttpMock, + json_codec: Jason + } + + expected = %{} + + assert expected == AssumeRolePodIdentityAdapter.adapt_auth_config(config, nil, @expiration) + end + + test "#adapt_auth_config returns empty map when config URI is nil" do + # Clear environment variable to ensure we're only testing config + System.delete_env("AWS_CONTAINER_CREDENTIALS_FULL_URI") + + config = %{ + container_credentials_uri: nil, + http_client: ExAws.Request.HttpMock, + json_codec: Jason + } + + expected = %{} + + assert expected == AssumeRolePodIdentityAdapter.adapt_auth_config(config, nil, @expiration) + end + end + + defp create_token_file(content) do + path = Path.join(System.tmp_dir!(), "test_token_#{System.unique_integer()}") + File.write!(path, content) + path + end +end diff --git a/test/lib/auth_cache/assume_role_web_identity_adapter_test.exs b/test/lib/auth_cache/assume_role_web_identity_adapter_test.exs index 5d0865b..de3377a 100644 --- a/test/lib/auth_cache/assume_role_web_identity_adapter_test.exs +++ b/test/lib/auth_cache/assume_role_web_identity_adapter_test.exs @@ -10,13 +10,14 @@ defmodule ExAws.STS.AuthCache.AssumeRoleWebIdentityAdapterTest do @expiration 30_000 setup do - {:ok, path} = Briefly.create() + path = Path.join(System.tmp_dir!(), "web_identity_token_#{System.unique_integer()}") File.write!(path, @web_identity_token) System.put_env("AWS_WEB_IDENTITY_TOKEN_FILE", path) System.put_env("AWS_ROLE_ARN", @role_arn) on_exit(fn -> + File.rm_rf(path) System.delete_env("AWS_WEB_IDENTITY_TOKEN_FILE") System.delete_env("AWS_ROLE_ARN") end)