Skip to content

Commit 8aea136

Browse files
committed
add eks pod identity credential provider
EKS Pod Identity provides improved security for Kubernetes workloads by offering pod-level credential isolation compared to instance roles. The implementation: - Automatically detects Pod Identity environment variables - Retrieves temporary AWS credentials via HTTP endpoint using JWT token - Follows existing credential provider patterns with caching and refresh logic - Integrates into default credential chain: env vars → pod identity → instance role - Uses raw JWT token format (not Bearer) as required by EKS Pod Identity service This enables seamless AWS API access for applications running on EKS clusters configured with Pod Identity associations.
1 parent bd0877c commit 8aea136

File tree

5 files changed

+190
-7
lines changed

5 files changed

+190
-7
lines changed

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,14 @@ the equivalent of:
5555

5656
```elixir
5757
config :ex_aws,
58-
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
59-
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role]
58+
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :pod_identity, :instance_role],
59+
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :pod_identity, :instance_role]
6060
```
6161

6262
This means it will try to resolve credentials in order:
6363

6464
* Look for the AWS standard `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables
65+
* Try to use [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) if running on EKS with Pod Identity configured
6566
* Resolve credentials with IAM
6667
* If running inside ECS and a [task role](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html) has been assigned it will use it
6768
* Otherwise it will fall back to the [instance role](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
@@ -80,10 +81,19 @@ variable, you can use that with `{:awscli, :system, timeout}`
8081

8182
```elixir
8283
config :ex_aws,
83-
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :instance_role],
84-
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, {:awscli, "default", 30}, :instance_role]
84+
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :pod_identity, :instance_role],
85+
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, {:awscli, "default", 30}, :pod_identity, :instance_role]
8586
```
8687

88+
### EKS Pod Identity configuration
89+
90+
For applications running on Amazon EKS, ExAws supports [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) for credential resolution. Pod Identity automatically injects the required environment variables into your pods when properly configured:
91+
92+
* `AWS_CONTAINER_CREDENTIALS_FULL_URI` - The endpoint URL for credential retrieval
93+
* `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` - Path to the JWT token file
94+
95+
No additional configuration is required in ExAws - it will automatically detect and use Pod Identity credentials when these environment variables are present. Pod Identity provides improved security and isolation compared to instance roles by providing pod-level credential scoping.
96+
8797
For role based authentication via `role_arn` and `source_profile` an additional
8898
dependency is required:
8999

lib/ex_aws/config.ex

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ defmodule ExAws.Config do
5050
:external_id
5151
]
5252

53+
@pod_identity_config [
54+
:access_key_id,
55+
:secret_access_key,
56+
:security_token
57+
]
58+
5359
@type t :: %{} | Keyword.t()
5460

5561
@doc """
@@ -107,6 +113,7 @@ defmodule ExAws.Config do
107113
Enum.reduce(refreshable, overrides, fn
108114
:awscli, overrides -> Map.drop(overrides, @awscli_config)
109115
:instance_role, overrides -> Map.drop(overrides, @instance_role_config)
116+
:pod_identity, overrides -> Map.drop(overrides, @pod_identity_config)
110117
end)
111118
else
112119
overrides
@@ -115,7 +122,7 @@ defmodule ExAws.Config do
115122
Map.merge(config, overrides)
116123
end
117124

118-
# :awscli and :instance_role both read creds from ExAws.Config.AuthCache which
125+
# :awscli, :instance_role, and :pod_identity all read creds from ExAws.Config.AuthCache which
119126
# is "refreshable". This is useful for long running streams where the creds can
120127
# change while the stream is still running.
121128
defp add_refreshable_metadata(config, %{refreshable: true}) do
@@ -124,6 +131,7 @@ defmodule ExAws.Config do
124131
|> Enum.reduce([], fn
125132
{:awscli, _, _}, acc -> [:awscli | acc]
126133
:instance_role, acc -> [:instance_role | acc]
134+
:pod_identity, acc -> [:pod_identity | acc]
127135
_, acc -> acc
128136
end)
129137
|> Enum.uniq()
@@ -181,6 +189,12 @@ defmodule ExAws.Config do
181189
|> valid_map_or_nil
182190
end
183191

192+
def retrieve_runtime_value(:pod_identity, config) do
193+
ExAws.Config.AuthCache.get(:pod_identity, config)
194+
|> Map.take(@pod_identity_config)
195+
|> valid_map_or_nil
196+
end
197+
184198
def retrieve_runtime_value({:awscli, profile, expiration}, _) do
185199
ExAws.Config.AuthCache.get(profile, expiration * 1000)
186200
|> Map.take(@awscli_config)

lib/ex_aws/config/auth_cache.ex

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule ExAws.Config.AuthCache do
77

88
@refresh_lead_time 300_000
99
@instance_auth_key :aws_instance_auth
10+
@pod_identity_auth_key :aws_pod_identity_auth
1011

1112
defmodule AuthConfigAdapter do
1213
@moduledoc false
@@ -24,6 +25,11 @@ defmodule ExAws.Config.AuthCache do
2425
|> refresh_auth_if_required(config)
2526
end
2627

28+
def get(:pod_identity, config) do
29+
:ets.lookup(__MODULE__, @pod_identity_auth_key)
30+
|> refresh_pod_identity_if_required(config)
31+
end
32+
2733
def get(profile, expiration) do
2834
case :ets.lookup(__MODULE__, {:awscli, profile}) do
2935
[{{:awscli, ^profile}, auth_config}] ->
@@ -46,6 +52,11 @@ defmodule ExAws.Config.AuthCache do
4652
{:reply, auth, ets}
4753
end
4854

55+
def handle_call({:refresh_pod_identity_auth, config}, _from, ets) do
56+
auth = refresh_pod_identity_auth(config, ets)
57+
{:reply, auth, ets}
58+
end
59+
4960
def handle_call({:refresh_awscli_config, profile, expiration}, _from, ets) do
5061
auth = refresh_awscli_config(profile, expiration, ets)
5162
{:reply, auth, ets}
@@ -56,6 +67,11 @@ defmodule ExAws.Config.AuthCache do
5667
{:noreply, ets}
5768
end
5869

70+
def handle_info({:refresh_pod_identity_auth, config}, ets) do
71+
refresh_pod_identity_auth(config, ets)
72+
{:noreply, ets}
73+
end
74+
5975
def handle_info({:refresh_awscli_config, profile, expiration}, ets) do
6076
refresh_awscli_config(profile, expiration, ets)
6177
{:noreply, ets}
@@ -135,6 +151,50 @@ defmodule ExAws.Config.AuthCache do
135151
auth
136152
end
137153

154+
defp refresh_pod_identity_if_required([], config) do
155+
GenServer.call(__MODULE__, {:refresh_pod_identity_auth, config}, 30_000)
156+
end
157+
158+
defp refresh_pod_identity_if_required([{_key, cached_auth}], config) do
159+
if next_refresh_in(cached_auth) > 0 do
160+
cached_auth
161+
else
162+
GenServer.call(__MODULE__, {:refresh_pod_identity_auth, config}, 30_000)
163+
end
164+
end
165+
166+
defp refresh_pod_identity_auth(config, ets) do
167+
:ets.lookup(__MODULE__, @pod_identity_auth_key)
168+
|> refresh_pod_identity_if_stale(config, ets)
169+
end
170+
171+
defp refresh_pod_identity_if_stale([], config, ets) do
172+
refresh_pod_identity_now(config, ets)
173+
end
174+
175+
defp refresh_pod_identity_if_stale([{_key, cached_auth}], config, ets) do
176+
if next_refresh_in(cached_auth) > @refresh_lead_time do
177+
cached_auth
178+
else
179+
refresh_pod_identity_now(config, ets)
180+
end
181+
end
182+
183+
defp refresh_pod_identity_if_stale(_, config, ets), do: refresh_pod_identity_now(config, ets)
184+
185+
defp refresh_pod_identity_now(config, ets) do
186+
# Only attempt pod identity if the environment is properly configured
187+
if ExAws.PodIdentity.available?() do
188+
auth = ExAws.PodIdentity.security_credentials(config)
189+
:ets.insert(ets, {@pod_identity_auth_key, auth})
190+
Process.send_after(__MODULE__, {:refresh_pod_identity_auth, config}, next_refresh_in(auth))
191+
auth
192+
else
193+
# Return empty map if pod identity is not available
194+
%{}
195+
end
196+
end
197+
138198
defp next_refresh_in(%{expiration: expiration}) do
139199
try do
140200
expires_in_ms =

lib/ex_aws/config/defaults.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ defmodule ExAws.Config.Defaults do
44
"""
55

66
@common %{
7-
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
8-
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role],
7+
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :pod_identity, :instance_role],
8+
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :pod_identity, :instance_role],
99
http_client: ExAws.Request.Hackney,
1010
json_codec: Jason,
1111
retries: [

lib/ex_aws/pod_identity.ex

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
defmodule ExAws.PodIdentity do
2+
@moduledoc false
3+
4+
# Provides access to AWS credentials via EKS Pod Identity
5+
# https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html
6+
7+
# Environment variables used by EKS Pod Identity
8+
@credentials_uri_env "AWS_CONTAINER_CREDENTIALS_FULL_URI"
9+
@authorization_token_file_env "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"
10+
11+
def available? do
12+
case {System.get_env(@credentials_uri_env), System.get_env(@authorization_token_file_env)} do
13+
{uri, token_file} when is_binary(uri) and is_binary(token_file) -> true
14+
_ -> false
15+
end
16+
end
17+
18+
def security_credentials(config) do
19+
with {:ok, token} <- read_authorization_token(),
20+
{:ok, credentials} <- fetch_credentials(config, token) do
21+
parse_credentials(credentials)
22+
else
23+
{:error, reason} ->
24+
raise """
25+
Pod Identity Error: #{inspect(reason)}
26+
27+
You tried to access AWS credentials via EKS Pod Identity, but it failed.
28+
This happens when the pod is not properly configured with Pod Identity
29+
or the required environment variables are not set.
30+
31+
Required environment variables:
32+
- #{@credentials_uri_env}
33+
- #{@authorization_token_file_env}
34+
35+
Please check your EKS Pod Identity configuration.
36+
"""
37+
end
38+
end
39+
40+
defp read_authorization_token do
41+
case System.get_env(@authorization_token_file_env) do
42+
nil ->
43+
{:error, "#{@authorization_token_file_env} environment variable not set"}
44+
45+
token_file_path ->
46+
case File.read(token_file_path) do
47+
{:ok, token} ->
48+
{:ok, String.trim(token)}
49+
50+
{:error, reason} ->
51+
{:error, "Failed to read authorization token from #{token_file_path}: #{reason}"}
52+
end
53+
end
54+
end
55+
56+
defp fetch_credentials(config, token) do
57+
case System.get_env(@credentials_uri_env) do
58+
nil ->
59+
{:error, "#{@credentials_uri_env} environment variable not set"}
60+
61+
credentials_uri ->
62+
headers = [{"Authorization", token}]
63+
64+
case config.http_client.request(:get, credentials_uri, "", headers, http_opts())
65+
|> ExAws.Request.maybe_transform_response() do
66+
{:ok, %{status_code: 200, body: body}} ->
67+
case config.json_codec.decode(body) do
68+
{:ok, credentials} -> {:ok, credentials}
69+
{:error, reason} -> {:error, "Failed to decode credentials JSON: #{reason}"}
70+
end
71+
72+
{:ok, %{status_code: status_code, body: body}} ->
73+
{:error, "HTTP #{status_code}: #{body}"}
74+
75+
{:error, reason} ->
76+
{:error, "HTTP request failed: #{reason}"}
77+
end
78+
end
79+
end
80+
81+
defp parse_credentials(credentials) do
82+
%{
83+
access_key_id: credentials["AccessKeyId"],
84+
secret_access_key: credentials["SecretAccessKey"],
85+
security_token: credentials["Token"],
86+
expiration: credentials["Expiration"]
87+
}
88+
end
89+
90+
defp http_opts do
91+
defaults = [follow_redirect: false, recv_timeout: 5_000]
92+
93+
overrides =
94+
Application.get_env(:ex_aws, :pod_identity, [])
95+
|> Keyword.get(:http_opts, [])
96+
97+
Keyword.merge(defaults, overrides)
98+
end
99+
end

0 commit comments

Comments
 (0)