From 66a2568710a065e0785dcf4349d853dfec551cc5 Mon Sep 17 00:00:00 2001 From: Nate Prewitt <nate.prewitt@gmail.com> Date: Thu, 22 May 2025 10:06:35 -0600 Subject: [PATCH 1/4] Add ContainerCredentialResolver --- .../src/smithy_aws_core/identity/container.py | 192 ++++++++++++ .../tests/unit/identity/test_container.py | 275 ++++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/container.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/test_container.py diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py new file mode 100644 index 000000000..30785c5e7 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py @@ -0,0 +1,192 @@ +import asyncio +import ipaddress +import json +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from urllib.parse import urlparse + +from smithy_core import URI +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyIdentityError +from smithy_http import Field, Fields +from smithy_http.aio import HTTPRequest +from smithy_http.aio.interfaces import HTTPClient, HTTPResponse + +from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties + +_CONTAINER_METADATA_IP = "169.254.170.2" +_CONTAINER_METADATA_ALLOWED_HOSTS = { + _CONTAINER_METADATA_IP, + "169.254.170.23", + "fd00:ec2::23", + "localhost", +} +_DEFAULT_TIMEOUT = 2 +_DEFAULT_RETRIES = 3 +_SLEEP_SECONDS = 1 + + +@dataclass +class ContainerCredentialConfig: + """Configuration for container credential retrieval operations.""" + + timeout: int = _DEFAULT_TIMEOUT + retries: int = _DEFAULT_RETRIES + + +class ContainerMetadataClient: + """Client for remote credential retrieval in Container environments like ECS/EKS.""" + + def __init__(self, http_client: HTTPClient, config: ContainerCredentialConfig): + self._http_client = http_client + self._config = config + + def _validate_allowed_url(self, uri: URI) -> None: + if self._is_loopback(uri.host): + return + + if not self._is_allowed_container_metadata_host(uri.host): + raise SmithyIdentityError( + f"Unsupported host '{uri.host}'. " + f"Can only retrieve metadata from a loopback address or " + f"one of: {', '.join(_CONTAINER_METADATA_ALLOWED_HOSTS)}" + ) + + async def get_credentials(self, uri: URI, fields: Fields) -> dict[str, str]: + self._validate_allowed_url(uri) + fields.set_field(Field(name="Accept", values=["application/json"])) + + attempts = 0 + last_exc = None + while attempts < self._config.retries: + try: + request = HTTPRequest( + method="GET", + destination=uri, + fields=fields, + ) + response: HTTPResponse = await self._http_client.send(request) + body = await response.consume_body_async() + if response.status != 200: + raise SmithyIdentityError( + f"Container metadata service returned {response.status}: " + f"{body.decode('utf-8')}" + ) + try: + return json.loads(body.decode("utf-8")) + except Exception as e: + raise SmithyIdentityError( + f"Unable to parse JSON from container metadata: {body.decode('utf-8')}" + ) from e + except Exception as e: + last_exc = e + await asyncio.sleep(_SLEEP_SECONDS) + attempts += 1 + + raise SmithyIdentityError( + f"Failed to retrieve container metadata after {self._config.retries} attempt(s)" + ) from last_exc + + def _is_loopback(self, hostname: str) -> bool: + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + def _is_allowed_container_metadata_host(self, hostname: str) -> bool: + return hostname in _CONTAINER_METADATA_ALLOWED_HOSTS + + +class ContainerCredentialResolver( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] +): + """Resolves AWS Credentials from container credential sources.""" + + ENV_VAR = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" + ENV_VAR_FULL = "AWS_CONTAINER_CREDENTIALS_FULL_URI" + ENV_VAR_AUTH_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN" # noqa: S105 + ENV_VAR_AUTH_TOKEN_FILE = "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE" # noqa: S105 + + def __init__( + self, + http_client: HTTPClient, + config: ContainerCredentialConfig | None = None, + ): + self._http_client = http_client + self._config = config or ContainerCredentialConfig() + self._client = ContainerMetadataClient(http_client, self._config) + self._credentials = None + + async def _resolve_uri_from_env(self) -> URI: + if self.ENV_VAR in os.environ: + return URI( + scheme="http", + host=_CONTAINER_METADATA_IP, + path=os.environ[self.ENV_VAR], + ) + elif self.ENV_VAR_FULL in os.environ: + parsed = urlparse(os.environ[self.ENV_VAR_FULL]) + return URI( + scheme=parsed.scheme, + host=parsed.hostname or "", + port=parsed.port, + path=parsed.path, + ) + else: + raise SmithyIdentityError( + f"Neither {self.ENV_VAR} or {self.ENV_VAR_FULL} environment " + "variables are set. Unable to resolve credentials." + ) + + async def _resolve_fields_from_env(self) -> Fields: + fields = Fields() + if self.ENV_VAR_AUTH_TOKEN_FILE in os.environ: + try: + filename = os.environ[self.ENV_VAR_AUTH_TOKEN_FILE] + auth_token = await asyncio.to_thread(self._read_file, filename) + except (FileNotFoundError, PermissionError) as e: + raise SmithyIdentityError( + f"Unable to open {os.environ[self.ENV_VAR_AUTH_TOKEN_FILE]}." + ) from e + + fields.set_field(Field(name="Authorization", values=[auth_token])) + elif self.ENV_VAR_AUTH_TOKEN in os.environ: + auth_token = os.environ[self.ENV_VAR_AUTH_TOKEN] + fields.set_field(Field(name="Authorization", values=[auth_token])) + + return fields + + def _read_file(self, filename: str) -> str: + with open(filename) as f: + return f.read().strip() + + async def get_identity( + self, *, properties: AWSIdentityProperties + ) -> AWSCredentialsIdentity: + uri = await self._resolve_uri_from_env() + fields = await self._resolve_fields_from_env() + creds = await self._client.get_credentials(uri, fields) + + access_key_id = creds.get("AccessKeyId") + secret_access_key = creds.get("SecretAccessKey") + session_token = creds.get("Token") + expiration = creds.get("Expiration") + account_id = creds.get("AccountId", None) + + if isinstance(expiration, str): + expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC) + + if access_key_id is None or secret_access_key is None: + raise SmithyIdentityError( + "AccessKeyId and SecretAccessKey are required for container credentials" + ) + + self._credentials = AWSCredentialsIdentity( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + session_token=session_token, + expiration=expiration, + account_id=account_id, + ) + return self._credentials diff --git a/packages/smithy-aws-core/tests/unit/identity/test_container.py b/packages/smithy-aws-core/tests/unit/identity/test_container.py new file mode 100644 index 000000000..d7941914a --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/test_container.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import json +import os +import typing +from unittest.mock import AsyncMock, patch + +import pytest +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.container import ( + ContainerCredentialConfig, + ContainerCredentialResolver, + ContainerMetadataClient, +) +from smithy_core import URI +from smithy_core.exceptions import SmithyIdentityError +from smithy_http import Fields + +if typing.TYPE_CHECKING: + import pathlib + +DEFAULT_RESPONSE_DATA = { + "AccessKeyId": "akid123", + "SecretAccessKey": "s3cr3t", + "Token": "session_token", +} + + +def test_config_custom_values(): + config = ContainerCredentialConfig(timeout=10, retries=5) + assert config.timeout == 10 + assert config.retries == 5 + + +def mock_http_client_response(status: int, body: bytes): + http_client = AsyncMock() + response = AsyncMock() + response.status = status + response.consume_body_async.return_value = body + http_client.send.return_value = response + return http_client + + +@pytest.mark.asyncio +async def test_metadata_client_valid_host(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + config = ContainerCredentialConfig() + client = ContainerMetadataClient(http_client, config) + + # Valid Host + uri = URI(scheme="http", host="169.254.170.2") + + creds = await client.get_credentials(uri, Fields()) + assert creds["AccessKeyId"] == "akid123" + assert creds["SecretAccessKey"] == "s3cr3t" + assert creds["Token"] == "session_token" + + +@pytest.mark.asyncio +async def test_metadata_client_invalid_host(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + config = ContainerCredentialConfig(retries=0) + client = ContainerMetadataClient(http_client, config) + + # Invalid Host + uri = URI(scheme="http", host="169.254.169.254") + + with pytest.raises(SmithyIdentityError): + await client.get_credentials(uri, Fields()) + + +@pytest.mark.asyncio +async def test_metadata_client_non_200_response(): + http_client = mock_http_client_response(404, b"not found") + config = ContainerCredentialConfig(retries=1) + client = ContainerMetadataClient(http_client, config) + + uri = URI(scheme="http", host="169.254.170.2") + with pytest.raises(SmithyIdentityError) as e: + await client.get_credentials(uri, Fields()) + + # Ensure both the received retry error and underlying error are what we expect. + assert "Container metadata service returned 404" in str(e.value.__cause__) + assert "Failed to retrieve container metadata after 1 attempt(s)" in str(e.value) + + +@pytest.mark.asyncio +async def test_metadata_client_invalid_json(): + http_client = mock_http_client_response( + 200, b"<!DOCTYPE html><head><title>proxy</title>" + ) + config = ContainerCredentialConfig(retries=1) + client = ContainerMetadataClient(http_client, config) + + uri = URI(scheme="http", host="169.254.170.2") + with pytest.raises(SmithyIdentityError): + await client.get_credentials(uri, Fields()) + + +def _assert_expected_identity(identity: AWSCredentialsIdentity) -> None: + assert identity.access_key_id == DEFAULT_RESPONSE_DATA["AccessKeyId"] + assert identity.secret_access_key == DEFAULT_RESPONSE_DATA["SecretAccessKey"] + assert identity.session_token == DEFAULT_RESPONSE_DATA["Token"] + + +@pytest.mark.asyncio +async def test_metadata_client_retries(): + http_client = AsyncMock() + config = ContainerCredentialConfig(retries=2) + client = ContainerMetadataClient(http_client, config) + uri = URI(scheme="http", host="169.254.170.2", path="/task") + http_client.send.side_effect = Exception() + + with pytest.raises(SmithyIdentityError): + await client.get_credentials(uri, Fields()) + assert http_client.send.call_count == 2 + + +@pytest.mark.asyncio +async def test_resolver_env_relative(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + with patch.dict(os.environ, {ContainerCredentialResolver.ENV_VAR: "/test"}): + resolver = ContainerCredentialResolver(http_client) + identity = await resolver.get_identity(properties={}) + + # Ensure we derive the correct destination + expected_url = URI( + scheme="http", + host="169.254.170.2", + path="/test", + ) + http_request = http_client.send.call_args_list[0].args[0] + assert http_request.destination == expected_url + + _assert_expected_identity(identity) + + +@pytest.mark.asyncio +async def test_resolver_env_full(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + with patch.dict( + os.environ, + {ContainerCredentialResolver.ENV_VAR_FULL: "http://169.254.170.23/full"}, + ): + resolver = ContainerCredentialResolver(http_client) + identity = await resolver.get_identity(properties={}) + + # Ensure we derive the correct destination + expected_url = URI( + scheme="http", + host="169.254.170.23", + path="/full", + ) + http_request = http_client.send.call_args_list[0].args[0] + assert http_request.destination == expected_url + + _assert_expected_identity(identity) + + +@pytest.mark.asyncio +async def test_resolver_env_token(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + with patch.dict( + os.environ, + { + ContainerCredentialResolver.ENV_VAR_FULL: "http://169.254.170.23/full", + ContainerCredentialResolver.ENV_VAR_AUTH_TOKEN: "Bearer foobar", + }, + ): + resolver = ContainerCredentialResolver(http_client) + identity = await resolver.get_identity(properties={}) + + # Ensure we derive the correct destination and fields + expected_url = URI( + scheme="http", + host="169.254.170.23", + path="/full", + ) + http_request = http_client.send.call_args_list[0].args[0] + assert http_request.destination == expected_url + + assert "Authorization" in http_request.fields + auth_field = http_request.fields.get("Authorization") + assert auth_field.as_string() == "Bearer foobar" + + _assert_expected_identity(identity) + + +@pytest.mark.asyncio +async def test_resolver_env_token_file(tmp_path: pathlib.Path): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + token_file = tmp_path / "token_file" + token_file.write_text("Bearer barfoo") + + with patch.dict( + os.environ, + { + ContainerCredentialResolver.ENV_VAR_FULL: "http://169.254.170.23/full", + ContainerCredentialResolver.ENV_VAR_AUTH_TOKEN_FILE: str(token_file), + }, + ): + resolver = ContainerCredentialResolver(http_client) + identity = await resolver.get_identity(properties={}) + + # Ensure we derive the correct destination and fields + expected_url = URI( + scheme="http", + host="169.254.170.23", + path="/full", + ) + http_request = http_client.send.call_args_list[0].args[0] + assert http_request.destination == expected_url + + assert "Authorization" in http_request.fields + auth_field = http_request.fields.get("Authorization") + assert auth_field.as_string() == "Bearer barfoo" + + _assert_expected_identity(identity) + + +@pytest.mark.asyncio +async def test_resolver_env_token_file_precedence(tmp_path: pathlib.Path): + """Validate the token file is used over the explicit value if both are set.""" + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + token_file = tmp_path / "token_file" + token_file.write_text("Bearer barfoo") + + with patch.dict( + os.environ, + { + ContainerCredentialResolver.ENV_VAR_FULL: "http://169.254.170.23/full", + ContainerCredentialResolver.ENV_VAR_AUTH_TOKEN_FILE: str(token_file), + ContainerCredentialResolver.ENV_VAR_AUTH_TOKEN: "Bearer foobar", + }, + ): + resolver = ContainerCredentialResolver(http_client) + identity = await resolver.get_identity(properties={}) + + # Ensure we derive the correct destination and fields + expected_url = URI( + scheme="http", + host="169.254.170.23", + path="/full", + ) + http_request = http_client.send.call_args_list[0].args[0] + assert http_request.destination == expected_url + + assert "Authorization" in http_request.fields + auth_field = http_request.fields.get("Authorization") + assert auth_field.as_string() == "Bearer barfoo" + + _assert_expected_identity(identity) + + +@pytest.mark.asyncio +async def test_resolver_missing_env(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + with patch.dict(os.environ, {}): + resolver = ContainerCredentialResolver(http_client) + with pytest.raises(SmithyIdentityError): + await resolver.get_identity(properties={}) From 017626504f66c3031745355d0a876621601ce124 Mon Sep 17 00:00:00 2001 From: Nate Prewitt <nate.prewitt@gmail.com> Date: Fri, 13 Jun 2025 13:23:10 -0600 Subject: [PATCH 2/4] PR Feedback --- .../src/smithy_aws_core/identity/__init__.py | 12 ++++++++++++ .../src/smithy_aws_core/identity/container.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index 94f6689a9..b2eb02e94 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -8,6 +8,18 @@ from smithy_core.interfaces.identity import Identity from smithy_core.types import PropertyKey +from .container import ContainerCredentialResolver +from .environment import EnvironmentCredentialsResolver +from .imds import IMDSCredentialsResolver +from .static import StaticCredentialsResolver + + +__all__ = ( + "ContainerCredentialResolver", + "EnvironmentCredentialsResolver", + "IMDSCredentialsResolver", + "StaticCredentialsResolver", +) @dataclass(kw_only=True) class AWSCredentialsIdentity(Identity): diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py index 30785c5e7..a14731c22 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py @@ -1,3 +1,5 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 import asyncio import ipaddress import json From aead51770ecd2a9a4f4c2933fe85429fb018e2b6 Mon Sep 17 00:00:00 2001 From: Nate Prewitt <nate.prewitt@gmail.com> Date: Tue, 12 Aug 2025 13:49:15 -0600 Subject: [PATCH 3/4] Port changes from #516 --- .../src/smithy_aws_core/identity/__init__.py | 2 +- .../src/smithy_aws_core/identity/container.py | 16 ++- .../tests/unit/identity/test_container.py | 102 +++++++++++++++++- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index b2eb02e94..f90da180d 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -13,7 +13,6 @@ from .imds import IMDSCredentialsResolver from .static import StaticCredentialsResolver - __all__ = ( "ContainerCredentialResolver", "EnvironmentCredentialsResolver", @@ -21,6 +20,7 @@ "StaticCredentialsResolver", ) + @dataclass(kw_only=True) class AWSCredentialsIdentity(Identity): access_key_id: str diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py index a14731c22..a8725317f 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/container.py @@ -161,11 +161,23 @@ async def _resolve_fields_from_env(self) -> Fields: def _read_file(self, filename: str) -> str: with open(filename) as f: - return f.read().strip() + try: + return f.read().strip() + except UnicodeDecodeError as e: + raise SmithyIdentityError( + f"Unable to read valid utf-8 bytes from {filename}." + ) from e async def get_identity( self, *, properties: AWSIdentityProperties ) -> AWSCredentialsIdentity: + if ( + self._credentials is not None + and self._credentials.expiration + and datetime.now(UTC) < self._credentials.expiration + ): + return self._credentials + uri = await self._resolve_uri_from_env() fields = await self._resolve_fields_from_env() creds = await self._client.get_credentials(uri, fields) @@ -174,7 +186,7 @@ async def get_identity( secret_access_key = creds.get("SecretAccessKey") session_token = creds.get("Token") expiration = creds.get("Expiration") - account_id = creds.get("AccountId", None) + account_id = creds.get("AccountId") if isinstance(expiration, str): expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_container.py b/packages/smithy-aws-core/tests/unit/identity/test_container.py index d7941914a..9c22893a5 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_container.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_container.py @@ -1,8 +1,11 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 from __future__ import annotations import json import os import typing +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, patch import pytest @@ -25,6 +28,8 @@ "Token": "session_token", } +ISO8601 = "%Y-%m-%dT%H:%M:%SZ" + def test_config_custom_values(): config = ContainerCredentialConfig(timeout=10, retries=5) @@ -41,20 +46,44 @@ def mock_http_client_response(status: int, body: bytes): return http_client +def _assert_expected_credentials( + creds: dict[str, str], access_key_id: str, secret_key_id: str, token: str +) -> None: + assert creds["AccessKeyId"] == access_key_id + assert creds["SecretAccessKey"] == secret_key_id + assert creds["Token"] == token + + @pytest.mark.asyncio -async def test_metadata_client_valid_host(): +@pytest.mark.parametrize( + "host", + ["169.254.170.2", "169.254.170.23", "fd00:ec2::23", "localhost", "127.0.0.2"], +) +async def test_metadata_client_valid_host(host: str): resp_body = json.dumps(DEFAULT_RESPONSE_DATA) http_client = mock_http_client_response(200, resp_body.encode("utf-8")) config = ContainerCredentialConfig() client = ContainerMetadataClient(http_client, config) # Valid Host - uri = URI(scheme="http", host="169.254.170.2") + uri = URI(scheme="http", host=host) + + creds = await client.get_credentials(uri, Fields()) + _assert_expected_credentials(creds, "akid123", "s3cr3t", "session_token") + + +@pytest.mark.asyncio +async def test_metadata_client_https_host(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + config = ContainerCredentialConfig() + client = ContainerMetadataClient(http_client, config) + + # Valid HTTPS Host + uri = URI(scheme="https", host="169.254.170.2") creds = await client.get_credentials(uri, Fields()) - assert creds["AccessKeyId"] == "akid123" - assert creds["SecretAccessKey"] == "s3cr3t" - assert creds["Token"] == "session_token" + _assert_expected_credentials(creds, "akid123", "s3cr3t", "session_token") @pytest.mark.asyncio @@ -228,6 +257,27 @@ async def test_resolver_env_token_file(tmp_path: pathlib.Path): _assert_expected_identity(identity) +@pytest.mark.asyncio +async def test_resolver_env_token_file_invalid_bytes(tmp_path: pathlib.Path): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + token_file = tmp_path / "token_file" + token_file.write_bytes(b"Bearer bar\xff\xfe\xfafoo") + + with patch.dict( + os.environ, + { + ContainerCredentialResolver.ENV_VAR_FULL: "http://169.254.170.23/full", + ContainerCredentialResolver.ENV_VAR_AUTH_TOKEN_FILE: str(token_file), + }, + ): + resolver = ContainerCredentialResolver(http_client) + with pytest.raises(SmithyIdentityError) as e: + await resolver.get_identity(properties={}) + assert "Unable to read valid utf-8 bytes from " in str(e.value) + + @pytest.mark.asyncio async def test_resolver_env_token_file_precedence(tmp_path: pathlib.Path): """Validate the token file is used over the explicit value if both are set.""" @@ -264,6 +314,48 @@ async def test_resolver_env_token_file_precedence(tmp_path: pathlib.Path): _assert_expected_identity(identity) +@pytest.mark.asyncio +async def test_resolver_valid_credentials_reused(): + custom_resp_data = dict(DEFAULT_RESPONSE_DATA) + current_time = datetime.now(UTC) + timedelta(minutes=10) + custom_resp_data["Expiration"] = current_time.strftime(ISO8601) + + resp_body = json.dumps(custom_resp_data) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + with patch.dict(os.environ, {ContainerCredentialResolver.ENV_VAR: "/test"}): + resolver = ContainerCredentialResolver(http_client) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + _assert_expected_identity(identity_one) + # Validate we got the same unexpired identity instance from both calls + assert identity_one is identity_two + + +@pytest.mark.asyncio +async def test_resolver_expired_credentials_refreshed(): + custom_resp_data = dict(DEFAULT_RESPONSE_DATA) + current_time = datetime.now(UTC) - timedelta(minutes=10) + custom_resp_data["Expiration"] = current_time.strftime(ISO8601) + + resp_body = json.dumps(custom_resp_data) + http_client = mock_http_client_response(200, resp_body.encode("utf-8")) + + with patch.dict(os.environ, {ContainerCredentialResolver.ENV_VAR: "/test"}): + resolver = ContainerCredentialResolver(http_client) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + _assert_expected_identity(identity_one) + + # Validate we got new credentials after we received an expired instance + assert identity_one.access_key_id == identity_two.access_key_id + assert identity_one.secret_access_key == identity_two.secret_access_key + assert identity_one.session_token == identity_two.session_token + assert identity_one is not identity_two + + @pytest.mark.asyncio async def test_resolver_missing_env(): resp_body = json.dumps(DEFAULT_RESPONSE_DATA) From 0e97365b1f348e7ee45806be890cdf0e53f534c9 Mon Sep 17 00:00:00 2001 From: Nate Prewitt <nate.prewitt@gmail.com> Date: Tue, 12 Aug 2025 14:16:15 -0600 Subject: [PATCH 4/4] Remove __all__ due to circular imports with the identity refactor --- .../src/smithy_aws_core/identity/__init__.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index f90da180d..94f6689a9 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -8,18 +8,6 @@ from smithy_core.interfaces.identity import Identity from smithy_core.types import PropertyKey -from .container import ContainerCredentialResolver -from .environment import EnvironmentCredentialsResolver -from .imds import IMDSCredentialsResolver -from .static import StaticCredentialsResolver - -__all__ = ( - "ContainerCredentialResolver", - "EnvironmentCredentialsResolver", - "IMDSCredentialsResolver", - "StaticCredentialsResolver", -) - @dataclass(kw_only=True) class AWSCredentialsIdentity(Identity):