Skip to content

Commit da253cb

Browse files
committed
feat: add ECS container credentials support for AWS workload identity federation
The `_DefaultAwsSecurityCredentialsSupplier` only supported EC2 IMDS for retrieving AWS credentials, which doesn't work on ECS. This adds support for the ECS container credentials endpoint via the standard AWS environment variables `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` and `AWS_CONTAINER_CREDENTIALS_FULL_URI`. The credential resolution order is now: 1. Static environment variables (AWS_ACCESS_KEY_ID, etc.) 2. ECS container credentials endpoint 3. EC2 IMDS Security: full URI validation follows AWS SDK behavior — loopback IPs and known ECS metadata IPs are allowed without a token; other hosts require `AWS_CONTAINER_AUTHORIZATION_TOKEN`.
1 parent 6876f64 commit da253cb

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

packages/google-auth/google/auth/aws.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import hashlib
4444
import hmac
4545
import http.client as http_client
46+
import ipaddress
4647
import json
4748
import os
4849
import posixpath
@@ -71,6 +72,10 @@
7172
)
7273
# IMDSV2 session token lifetime. This is set to a low value because the session token is used immediately.
7374
_IMDSV2_SESSION_TOKEN_TTL_SECONDS = "300"
75+
# Base URL for ECS container credentials endpoint (relative URI).
76+
_ECS_CONTAINER_CREDENTIALS_BASE_URL = "http://169.254.170.2"
77+
# Known ECS metadata IPs that are allowed without an authorization token.
78+
_ALLOWED_CONTAINER_METADATA_IPS = frozenset(["169.254.170.2", "169.254.170.23"])
7479

7580

7681
class RequestSigner(object):
@@ -433,6 +438,11 @@ def get_aws_security_credentials(self, context, request):
433438
env_aws_access_key_id, env_aws_secret_access_key, env_aws_session_token
434439
)
435440

441+
# Check for ECS container credentials before falling back to IMDS.
442+
ecs_credentials = self._get_ecs_security_credentials(request)
443+
if ecs_credentials is not None:
444+
return ecs_credentials
445+
436446
imdsv2_session_token = self._get_imdsv2_session_token(request)
437447
role_name = self._get_metadata_role_name(request, imdsv2_session_token)
438448

@@ -486,6 +496,87 @@ def get_aws_region(self, context, request):
486496
# Only the us-east-2 part should be used.
487497
return response_body[:-1]
488498

499+
def _get_ecs_security_credentials(self, request):
500+
"""Retrieves AWS security credentials from the ECS container credentials
501+
endpoint if available.
502+
503+
Args:
504+
request (google.auth.transport.Request): A callable used to make
505+
HTTP requests.
506+
507+
Returns:
508+
Optional[AwsSecurityCredentials]: The AWS security credentials from
509+
ECS, or None if ECS environment variables are not set.
510+
511+
Raises:
512+
google.auth.exceptions.RefreshError: If an error occurs while
513+
retrieving credentials from the ECS endpoint.
514+
"""
515+
# Check full URI first, then relative URI.
516+
full_uri = os.environ.get(
517+
environment_vars.AWS_CONTAINER_CREDENTIALS_FULL_URI
518+
)
519+
relative_uri = os.environ.get(
520+
environment_vars.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
521+
)
522+
523+
if full_uri:
524+
url = full_uri
525+
# Validate the full URI host for security.
526+
parsed = urllib.parse.urlparse(url)
527+
hostname = parsed.hostname
528+
try:
529+
addr = ipaddress.ip_address(hostname)
530+
is_allowed = addr.is_loopback or str(addr) in _ALLOWED_CONTAINER_METADATA_IPS
531+
except ValueError:
532+
# Not an IP address (e.g., a hostname). Treat as non-loopback.
533+
is_allowed = False
534+
535+
if not is_allowed:
536+
# Non-loopback, non-ECS IP requires an authorization token.
537+
auth_token = os.environ.get(
538+
environment_vars.AWS_CONTAINER_AUTHORIZATION_TOKEN
539+
)
540+
if not auth_token:
541+
raise exceptions.RefreshError(
542+
"AWS_CONTAINER_CREDENTIALS_FULL_URI is set to a non-loopback "
543+
"address but AWS_CONTAINER_AUTHORIZATION_TOKEN is not set."
544+
)
545+
elif relative_uri:
546+
url = _ECS_CONTAINER_CREDENTIALS_BASE_URL + relative_uri
547+
else:
548+
return None
549+
550+
headers = {}
551+
auth_token = os.environ.get(
552+
environment_vars.AWS_CONTAINER_AUTHORIZATION_TOKEN
553+
)
554+
if auth_token:
555+
headers["Authorization"] = auth_token
556+
557+
response = request(url=url, method="GET", headers=headers or None)
558+
559+
response_body = (
560+
response.data.decode("utf-8")
561+
if hasattr(response.data, "decode")
562+
else response.data
563+
)
564+
565+
if response.status != http_client.OK:
566+
raise exceptions.RefreshError(
567+
"Unable to retrieve AWS security credentials from ECS: {}".format(
568+
response_body
569+
)
570+
)
571+
572+
credentials_response = json.loads(response_body)
573+
574+
return AwsSecurityCredentials(
575+
credentials_response.get("AccessKeyId"),
576+
credentials_response.get("SecretAccessKey"),
577+
credentials_response.get("Token"),
578+
)
579+
489580
def _get_imdsv2_session_token(self, request):
490581
if request is not None and self._imdsv2_session_token_url is not None:
491582
headers = {

packages/google-auth/google/auth/environment_vars.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@
105105
AWS_REGION = "AWS_REGION"
106106
AWS_DEFAULT_REGION = "AWS_DEFAULT_REGION"
107107

108+
# AWS ECS container credentials environment variables.
109+
# These are used to retrieve temporary AWS credentials from the ECS container
110+
# credentials endpoint when running on AWS ECS.
111+
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
112+
AWS_CONTAINER_CREDENTIALS_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI"
113+
AWS_CONTAINER_AUTHORIZATION_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN"
114+
108115
GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED"
109116
"""Environment variable controlling whether to enable trust boundary feature.
110117
The default value is false. Users have to explicitly set this value to true."""

packages/google-auth/tests/test_aws.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2456,3 +2456,256 @@ def test_refresh_success_with_supplier(self, utcnow, mock_auth_lib_value):
24562456
assert credentials.quota_project_id == QUOTA_PROJECT_ID
24572457
assert credentials.scopes == SCOPES
24582458
assert credentials.default_scopes == ["ignored"]
2459+
2460+
@mock.patch("google.auth._helpers.utcnow")
2461+
def test_retrieve_subject_token_success_ecs_relative_uri(
2462+
self, utcnow, monkeypatch
2463+
):
2464+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2465+
monkeypatch.setenv(
2466+
environment_vars.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
2467+
"/v2/credentials/role-id",
2468+
)
2469+
utcnow.return_value = datetime.datetime.strptime(
2470+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2471+
)
2472+
# Mock the ECS credentials endpoint response.
2473+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2474+
ecs_response.status = http_client.OK
2475+
ecs_response.data = json.dumps(
2476+
self.AWS_SECURITY_CREDENTIALS_RESPONSE
2477+
).encode("utf-8")
2478+
request = mock.create_autospec(transport.Request)
2479+
request.side_effect = [ecs_response]
2480+
2481+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2482+
subject_token = credentials.retrieve_subject_token(request)
2483+
2484+
assert subject_token == self.make_serialized_aws_signed_request(
2485+
aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN)
2486+
)
2487+
# Assert ECS credentials request.
2488+
assert request.call_args_list[0][1]["url"] == (
2489+
"http://169.254.170.2/v2/credentials/role-id"
2490+
)
2491+
assert request.call_args_list[0][1]["method"] == "GET"
2492+
2493+
@mock.patch("google.auth._helpers.utcnow")
2494+
def test_retrieve_subject_token_success_ecs_full_uri_loopback(
2495+
self, utcnow, monkeypatch
2496+
):
2497+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2498+
monkeypatch.setenv(
2499+
environment_vars.AWS_CONTAINER_CREDENTIALS_FULL_URI,
2500+
"http://127.0.0.1/v2/credentials/role-id",
2501+
)
2502+
utcnow.return_value = datetime.datetime.strptime(
2503+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2504+
)
2505+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2506+
ecs_response.status = http_client.OK
2507+
ecs_response.data = json.dumps(
2508+
self.AWS_SECURITY_CREDENTIALS_RESPONSE
2509+
).encode("utf-8")
2510+
request = mock.create_autospec(transport.Request)
2511+
request.side_effect = [ecs_response]
2512+
2513+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2514+
subject_token = credentials.retrieve_subject_token(request)
2515+
2516+
assert subject_token == self.make_serialized_aws_signed_request(
2517+
aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN)
2518+
)
2519+
assert request.call_args_list[0][1]["url"] == (
2520+
"http://127.0.0.1/v2/credentials/role-id"
2521+
)
2522+
2523+
@mock.patch("google.auth._helpers.utcnow")
2524+
def test_retrieve_subject_token_success_ecs_full_uri_loopback_ipv6(
2525+
self, utcnow, monkeypatch
2526+
):
2527+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2528+
monkeypatch.setenv(
2529+
environment_vars.AWS_CONTAINER_CREDENTIALS_FULL_URI,
2530+
"http://[::1]/v2/credentials/role-id",
2531+
)
2532+
utcnow.return_value = datetime.datetime.strptime(
2533+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2534+
)
2535+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2536+
ecs_response.status = http_client.OK
2537+
ecs_response.data = json.dumps(
2538+
self.AWS_SECURITY_CREDENTIALS_RESPONSE
2539+
).encode("utf-8")
2540+
request = mock.create_autospec(transport.Request)
2541+
request.side_effect = [ecs_response]
2542+
2543+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2544+
subject_token = credentials.retrieve_subject_token(request)
2545+
2546+
assert subject_token == self.make_serialized_aws_signed_request(
2547+
aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN)
2548+
)
2549+
2550+
@mock.patch("google.auth._helpers.utcnow")
2551+
def test_retrieve_subject_token_success_ecs_full_uri_with_auth_token(
2552+
self, utcnow, monkeypatch
2553+
):
2554+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2555+
monkeypatch.setenv(
2556+
environment_vars.AWS_CONTAINER_CREDENTIALS_FULL_URI,
2557+
"http://192.168.1.1/v2/credentials/role-id",
2558+
)
2559+
monkeypatch.setenv(
2560+
environment_vars.AWS_CONTAINER_AUTHORIZATION_TOKEN,
2561+
"my-auth-token",
2562+
)
2563+
utcnow.return_value = datetime.datetime.strptime(
2564+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2565+
)
2566+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2567+
ecs_response.status = http_client.OK
2568+
ecs_response.data = json.dumps(
2569+
self.AWS_SECURITY_CREDENTIALS_RESPONSE
2570+
).encode("utf-8")
2571+
request = mock.create_autospec(transport.Request)
2572+
request.side_effect = [ecs_response]
2573+
2574+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2575+
subject_token = credentials.retrieve_subject_token(request)
2576+
2577+
assert subject_token == self.make_serialized_aws_signed_request(
2578+
aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN)
2579+
)
2580+
# Assert Authorization header was sent.
2581+
assert request.call_args_list[0][1]["headers"]["Authorization"] == "my-auth-token"
2582+
2583+
@mock.patch("google.auth._helpers.utcnow")
2584+
def test_retrieve_subject_token_ecs_full_uri_non_loopback_without_token_raises(
2585+
self, utcnow, monkeypatch
2586+
):
2587+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2588+
monkeypatch.setenv(
2589+
environment_vars.AWS_CONTAINER_CREDENTIALS_FULL_URI,
2590+
"http://192.168.1.1/v2/credentials/role-id",
2591+
)
2592+
utcnow.return_value = datetime.datetime.strptime(
2593+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2594+
)
2595+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2596+
2597+
with pytest.raises(exceptions.RefreshError) as exc_info:
2598+
credentials.retrieve_subject_token(None)
2599+
2600+
assert "AWS_CONTAINER_CREDENTIALS_FULL_URI" in str(exc_info.value)
2601+
assert "AWS_CONTAINER_AUTHORIZATION_TOKEN" in str(exc_info.value)
2602+
2603+
@mock.patch("google.auth._helpers.utcnow")
2604+
def test_retrieve_subject_token_ecs_full_uri_takes_precedence_over_relative(
2605+
self, utcnow, monkeypatch
2606+
):
2607+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2608+
monkeypatch.setenv(
2609+
environment_vars.AWS_CONTAINER_CREDENTIALS_FULL_URI,
2610+
"http://127.0.0.1/full-uri-path",
2611+
)
2612+
monkeypatch.setenv(
2613+
environment_vars.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
2614+
"/relative-uri-path",
2615+
)
2616+
utcnow.return_value = datetime.datetime.strptime(
2617+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2618+
)
2619+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2620+
ecs_response.status = http_client.OK
2621+
ecs_response.data = json.dumps(
2622+
self.AWS_SECURITY_CREDENTIALS_RESPONSE
2623+
).encode("utf-8")
2624+
request = mock.create_autospec(transport.Request)
2625+
request.side_effect = [ecs_response]
2626+
2627+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2628+
credentials.retrieve_subject_token(request)
2629+
2630+
# Full URI should be used, not relative URI.
2631+
assert request.call_args_list[0][1]["url"] == "http://127.0.0.1/full-uri-path"
2632+
2633+
@mock.patch("google.auth._helpers.utcnow")
2634+
def test_retrieve_subject_token_ecs_takes_precedence_over_imds(
2635+
self, utcnow, monkeypatch
2636+
):
2637+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2638+
monkeypatch.setenv(
2639+
environment_vars.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
2640+
"/v2/credentials/role-id",
2641+
)
2642+
utcnow.return_value = datetime.datetime.strptime(
2643+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2644+
)
2645+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2646+
ecs_response.status = http_client.OK
2647+
ecs_response.data = json.dumps(
2648+
self.AWS_SECURITY_CREDENTIALS_RESPONSE
2649+
).encode("utf-8")
2650+
request = mock.create_autospec(transport.Request)
2651+
request.side_effect = [ecs_response]
2652+
2653+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2654+
subject_token = credentials.retrieve_subject_token(request)
2655+
2656+
assert subject_token == self.make_serialized_aws_signed_request(
2657+
aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN)
2658+
)
2659+
# Only 1 request (ECS), no IMDS requests.
2660+
assert len(request.call_args_list) == 1
2661+
2662+
@mock.patch("google.auth._helpers.utcnow")
2663+
def test_retrieve_subject_token_env_vars_take_precedence_over_ecs(
2664+
self, utcnow, monkeypatch
2665+
):
2666+
monkeypatch.setenv(environment_vars.AWS_ACCESS_KEY_ID, ACCESS_KEY_ID)
2667+
monkeypatch.setenv(environment_vars.AWS_SECRET_ACCESS_KEY, SECRET_ACCESS_KEY)
2668+
monkeypatch.setenv(environment_vars.AWS_SESSION_TOKEN, TOKEN)
2669+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2670+
monkeypatch.setenv(
2671+
environment_vars.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
2672+
"/v2/credentials/role-id",
2673+
)
2674+
utcnow.return_value = datetime.datetime.strptime(
2675+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2676+
)
2677+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2678+
2679+
# No request mock needed - env vars should be used directly.
2680+
subject_token = credentials.retrieve_subject_token(None)
2681+
2682+
assert subject_token == self.make_serialized_aws_signed_request(
2683+
aws.AwsSecurityCredentials(ACCESS_KEY_ID, SECRET_ACCESS_KEY, TOKEN)
2684+
)
2685+
2686+
@mock.patch("google.auth._helpers.utcnow")
2687+
def test_retrieve_subject_token_ecs_endpoint_error(
2688+
self, utcnow, monkeypatch
2689+
):
2690+
monkeypatch.setenv(environment_vars.AWS_REGION, self.AWS_REGION)
2691+
monkeypatch.setenv(
2692+
environment_vars.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
2693+
"/v2/credentials/role-id",
2694+
)
2695+
utcnow.return_value = datetime.datetime.strptime(
2696+
self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ"
2697+
)
2698+
ecs_response = mock.create_autospec(transport.Response, instance=True)
2699+
ecs_response.status = http_client.UNAUTHORIZED
2700+
ecs_response.data = b"Unauthorized"
2701+
request = mock.create_autospec(transport.Request)
2702+
request.side_effect = [ecs_response]
2703+
2704+
credentials = self.make_credentials(credential_source=self.CREDENTIAL_SOURCE)
2705+
2706+
with pytest.raises(exceptions.RefreshError) as exc_info:
2707+
credentials.retrieve_subject_token(request)
2708+
2709+
assert "Unable to retrieve AWS security credentials from ECS" in str(
2710+
exc_info.value
2711+
)

0 commit comments

Comments
 (0)