From 7afbbc0bae3d5a3ad931b9cf67456faa36bd1f10 Mon Sep 17 00:00:00 2001 From: Antonio Aranda <102337110+arandito@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:24:13 -0400 Subject: [PATCH 1/2] Add sts, imds, and http credential provider packages --- ...ture-cb78a08a2d6149668222fc4ee762070a.json | 4 + packages/aws-credentials-http/NOTICE | 1 + packages/aws-credentials-http/README.md | 5 + packages/aws-credentials-http/pyproject.toml | 56 ++ .../src/aws_credentials_http/__init__.py | 11 + .../src/aws_credentials_http/client.py | 93 +++ .../src/aws_credentials_http/providers.py | 41 + .../src/aws_credentials_http/py.typed | 1 + .../src/aws_credentials_http/resolvers.py | 134 +++ .../tests/unit/test_client.py | 108 +++ .../tests/unit/test_providers.py | 115 +++ .../tests/unit/test_resolvers.py | 237 ++++++ ...ture-a3999a2d64084b49a4da9d9dea1773a1.json | 4 + packages/aws-credentials-imds/NOTICE | 1 + packages/aws-credentials-imds/README.md | 5 + packages/aws-credentials-imds/pyproject.toml | 56 ++ .../src/aws_credentials_imds/__init__.py | 13 + .../src/aws_credentials_imds/client.py | 178 ++++ .../src/aws_credentials_imds/providers.py | 133 +++ .../src/aws_credentials_imds/py.typed | 1 + .../src/aws_credentials_imds/resolvers.py | 72 ++ .../tests/unit/test_client.py | 146 ++++ .../tests/unit/test_providers.py | 295 +++++++ .../tests/unit/test_resolvers.py | 145 ++++ ...ture-31813506190e4e598cd79e3271662d6a.json | 4 + packages/aws-credentials-sts/NOTICE | 1 + packages/aws-credentials-sts/README.md | 5 + packages/aws-credentials-sts/pyproject.toml | 57 ++ .../src/aws_credentials_sts/__init__.py | 17 + .../src/aws_credentials_sts/providers.py | 47 ++ .../src/aws_credentials_sts/py.typed | 1 + .../src/aws_credentials_sts/resolvers.py | 367 ++++++++ .../tests/unit/test_providers.py | 118 +++ .../tests/unit/test_resolvers.py | 789 ++++++++++++++++++ 34 files changed, 3261 insertions(+) create mode 100644 packages/aws-credentials-http/.changes/next-release/aws-credentials-http-feature-cb78a08a2d6149668222fc4ee762070a.json create mode 100644 packages/aws-credentials-http/NOTICE create mode 100644 packages/aws-credentials-http/README.md create mode 100644 packages/aws-credentials-http/pyproject.toml create mode 100644 packages/aws-credentials-http/src/aws_credentials_http/__init__.py create mode 100644 packages/aws-credentials-http/src/aws_credentials_http/client.py create mode 100644 packages/aws-credentials-http/src/aws_credentials_http/providers.py create mode 100644 packages/aws-credentials-http/src/aws_credentials_http/py.typed create mode 100644 packages/aws-credentials-http/src/aws_credentials_http/resolvers.py create mode 100644 packages/aws-credentials-http/tests/unit/test_client.py create mode 100644 packages/aws-credentials-http/tests/unit/test_providers.py create mode 100644 packages/aws-credentials-http/tests/unit/test_resolvers.py create mode 100644 packages/aws-credentials-imds/.changes/next-release/aws-credentials-imds-feature-a3999a2d64084b49a4da9d9dea1773a1.json create mode 100644 packages/aws-credentials-imds/NOTICE create mode 100644 packages/aws-credentials-imds/README.md create mode 100644 packages/aws-credentials-imds/pyproject.toml create mode 100644 packages/aws-credentials-imds/src/aws_credentials_imds/__init__.py create mode 100644 packages/aws-credentials-imds/src/aws_credentials_imds/client.py create mode 100644 packages/aws-credentials-imds/src/aws_credentials_imds/providers.py create mode 100644 packages/aws-credentials-imds/src/aws_credentials_imds/py.typed create mode 100644 packages/aws-credentials-imds/src/aws_credentials_imds/resolvers.py create mode 100644 packages/aws-credentials-imds/tests/unit/test_client.py create mode 100644 packages/aws-credentials-imds/tests/unit/test_providers.py create mode 100644 packages/aws-credentials-imds/tests/unit/test_resolvers.py create mode 100644 packages/aws-credentials-sts/.changes/next-release/aws-credentials-sts-feature-31813506190e4e598cd79e3271662d6a.json create mode 100644 packages/aws-credentials-sts/NOTICE create mode 100644 packages/aws-credentials-sts/README.md create mode 100644 packages/aws-credentials-sts/pyproject.toml create mode 100644 packages/aws-credentials-sts/src/aws_credentials_sts/__init__.py create mode 100644 packages/aws-credentials-sts/src/aws_credentials_sts/providers.py create mode 100644 packages/aws-credentials-sts/src/aws_credentials_sts/py.typed create mode 100644 packages/aws-credentials-sts/src/aws_credentials_sts/resolvers.py create mode 100644 packages/aws-credentials-sts/tests/unit/test_providers.py create mode 100644 packages/aws-credentials-sts/tests/unit/test_resolvers.py diff --git a/packages/aws-credentials-http/.changes/next-release/aws-credentials-http-feature-cb78a08a2d6149668222fc4ee762070a.json b/packages/aws-credentials-http/.changes/next-release/aws-credentials-http-feature-cb78a08a2d6149668222fc4ee762070a.json new file mode 100644 index 0000000..2d0efcd --- /dev/null +++ b/packages/aws-credentials-http/.changes/next-release/aws-credentials-http-feature-cb78a08a2d6149668222fc4ee762070a.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Add container HTTP credentials resolver and `EcsContainer` chain provider." +} diff --git a/packages/aws-credentials-http/NOTICE b/packages/aws-credentials-http/NOTICE new file mode 100644 index 0000000..616fc58 --- /dev/null +++ b/packages/aws-credentials-http/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/aws-credentials-http/README.md b/packages/aws-credentials-http/README.md new file mode 100644 index 0000000..509d047 --- /dev/null +++ b/packages/aws-credentials-http/README.md @@ -0,0 +1,5 @@ +# aws-credentials-http + +This package provides a container HTTP credential resolver and chain provider. +Installing it automatically adds the `ECS_CONTAINER` source to the SDK's modular +AWS credential chain. diff --git a/packages/aws-credentials-http/pyproject.toml b/packages/aws-credentials-http/pyproject.toml new file mode 100644 index 0000000..4b50c8c --- /dev/null +++ b/packages/aws-credentials-http/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "aws-credentials-http" +dynamic = ["version"] +requires-python = ">=3.12" +authors = [ + {name = "Amazon Web Services"}, +] +description = "HTTP endpoint credentials support for the AWS SDK for Python." +readme = "README.md" +license = {text = "Apache License 2.0"} +keywords = ["aws", "credentials", "http", "ecs", "eks", "sdk", "smithy"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Free Threading :: 2 - Beta", + "Topic :: Software Development :: Libraries", +] +# TODO: Sync these with latest release before merging +dependencies = [ + "smithy-aws-core~=0.7.0", + "smithy-core~=0.6.0", + "smithy-http[aiohttp]~=0.4.0", +] + +[project.urls] +"Code" = "https://github.com/aws/aws-sdk-python/tree/develop/packages/aws-credentials-http/" +"Issue tracker" = "https://github.com/aws/aws-sdk-python/issues" + +[project.entry-points."smithy_aws_core.identity.chain_providers"] +EcsContainer = "aws_credentials_http.providers:EcsContainerProvider" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/aws_credentials_http/__init__.py" + +[tool.hatch.build] +exclude = [ + "tests", +] + +[tool.ruff] +src = ["src"] diff --git a/packages/aws-credentials-http/src/aws_credentials_http/__init__.py b/packages/aws-credentials-http/src/aws_credentials_http/__init__.py new file mode 100644 index 0000000..ae3747b --- /dev/null +++ b/packages/aws-credentials-http/src/aws_credentials_http/__init__.py @@ -0,0 +1,11 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +__version__ = "0.0.0" + +from .providers import EcsContainerProvider +from .resolvers import ContainerCredentialsResolver + +__all__ = ( + "ContainerCredentialsResolver", + "EcsContainerProvider", +) diff --git a/packages/aws-credentials-http/src/aws_credentials_http/client.py b/packages/aws-credentials-http/src/aws_credentials_http/client.py new file mode 100644 index 0000000..dbdd746 --- /dev/null +++ b/packages/aws-credentials-http/src/aws_credentials_http/client.py @@ -0,0 +1,93 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import asyncio +import ipaddress +import json + +from smithy_core import URI +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 + +_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 + + +class HttpCredentialsClient: + """Retrieves AWS credentials from an HTTP credentials endpoint.""" + + def __init__( + self, + http_client: HTTPClient, + *, + timeout: int = _DEFAULT_TIMEOUT, + retries: int = _DEFAULT_RETRIES, + ): + self._http_client = http_client + self._timeout = timeout + self._retries = retries + + 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._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 error: + raise SmithyIdentityError( + "Unable to parse JSON from container metadata: " + f"{body.decode('utf-8')}" + ) from error + except Exception as error: + last_exc = error + await asyncio.sleep(_SLEEP_SECONDS) + attempts += 1 + + raise SmithyIdentityError( + f"Failed to retrieve container metadata after {self._retries} attempt(s)" + ) from last_exc + + 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)}" + ) + + 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 diff --git a/packages/aws-credentials-http/src/aws_credentials_http/providers.py b/packages/aws-credentials-http/src/aws_credentials_http/providers.py new file mode 100644 index 0000000..45bb234 --- /dev/null +++ b/packages/aws-credentials-http/src/aws_credentials_http/providers.py @@ -0,0 +1,41 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os + +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain import Standard, StandardProvider +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_core.interfaces.identity import Identity + +from .resolvers import ContainerCredentialsResolver + +_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" +_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI" + + +class EcsContainerProvider: + """Adds a container credential resolver to the credential chain.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.ECS_CONTAINER.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.ECS_CONTAINER) + + async def setup( + self, + identity_type: type[Identity], + setup: ChainSetup, + ) -> None: + """Add a terminal resolver when a container endpoint is configured.""" + if identity_type is not AWSCredentialsIdentity: + return + if not os.getenv(_RELATIVE_URI) and not os.getenv(_FULL_URI): + return + setup.add_terminal_resolver( + ContainerCredentialsResolver(http_client=setup.http_client) + ) diff --git a/packages/aws-credentials-http/src/aws_credentials_http/py.typed b/packages/aws-credentials-http/src/aws_credentials_http/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/aws-credentials-http/src/aws_credentials_http/py.typed @@ -0,0 +1 @@ + diff --git a/packages/aws-credentials-http/src/aws_credentials_http/resolvers.py b/packages/aws-credentials-http/src/aws_credentials_http/resolvers.py new file mode 100644 index 0000000..f745835 --- /dev/null +++ b/packages/aws-credentials-http/src/aws_credentials_http/resolvers.py @@ -0,0 +1,134 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import asyncio +import os +from datetime import UTC, datetime +from urllib.parse import urlparse + +from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +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.aiohttp import AIOHTTPClient +from smithy_http.aio.interfaces import HTTPClient + +from .client import HttpCredentialsClient + +_CONTAINER_METADATA_IP = "169.254.170.2" +_DEFAULT_TIMEOUT = 2 +_DEFAULT_RETRIES = 3 + + +class ContainerCredentialsResolver( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] +): + """Resolves AWS credentials from container HTTP endpoints.""" + + 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 | None = None, + *, + timeout: int = _DEFAULT_TIMEOUT, + retries: int = _DEFAULT_RETRIES, + ): + self._http_client = http_client or AIOHTTPClient() + self._client = HttpCredentialsClient( + self._http_client, timeout=timeout, retries=retries + ) + self._credentials = None + + async def get_identity( + self, *, properties: AWSIdentityProperties + ) -> AWSCredentialsIdentity: + """Return cached credentials if valid, otherwise fetch from container endpoint.""" + 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) + + 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") + + 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 + + async def invalidate(self) -> None: + """Discard cached credentials so the next resolution re-queries the endpoint.""" + 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 error: + raise SmithyIdentityError( + f"Unable to open {os.environ[self.ENV_VAR_AUTH_TOKEN_FILE]}." + ) from error + + 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 token_file: + try: + return token_file.read().strip() + except UnicodeDecodeError as error: + raise SmithyIdentityError( + f"Unable to read valid utf-8 bytes from {filename}." + ) from error diff --git a/packages/aws-credentials-http/tests/unit/test_client.py b/packages/aws-credentials-http/tests/unit/test_client.py new file mode 100644 index 0000000..3740324 --- /dev/null +++ b/packages/aws-credentials-http/tests/unit/test_client.py @@ -0,0 +1,108 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import json +from unittest.mock import AsyncMock + +import pytest +from aws_credentials_http.client import HttpCredentialsClient +from smithy_core import URI +from smithy_core.exceptions import SmithyIdentityError +from smithy_http import Fields + +DEFAULT_RESPONSE_DATA = { + "AccessKeyId": "akid123", + "SecretAccessKey": "s3cr3t", + "Token": "session_token", +} + + +def mock_http_client_response(status: int, body: bytes) -> AsyncMock: + http_client = AsyncMock() + response = AsyncMock() + response.status = status + response.consume_body_async.return_value = body + http_client.send.return_value = response + return http_client + + +def _assert_expected_credentials( + credentials: dict[str, str], + access_key_id: str, + secret_access_key: str, + token: str, +) -> None: + assert credentials["AccessKeyId"] == access_key_id + assert credentials["SecretAccessKey"] == secret_access_key + assert credentials["Token"] == token + + +@pytest.mark.parametrize( + "host", + ["169.254.170.2", "169.254.170.23", "fd00:ec2::23", "localhost", "127.0.0.2"], +) +async def test_client_valid_host(host: str) -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + client = HttpCredentialsClient(http_client) + + credentials = await client.get_credentials(URI(scheme="http", host=host), Fields()) + + _assert_expected_credentials(credentials, "akid123", "s3cr3t", "session_token") + + +async def test_client_https_host() -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + client = HttpCredentialsClient(http_client) + + credentials = await client.get_credentials( + URI(scheme="https", host="169.254.170.2"), Fields() + ) + + _assert_expected_credentials(credentials, "akid123", "s3cr3t", "session_token") + + +async def test_client_invalid_host() -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + client = HttpCredentialsClient(http_client, retries=0) + + with pytest.raises(SmithyIdentityError): + await client.get_credentials( + URI(scheme="http", host="169.254.169.254"), Fields() + ) + + +async def test_client_non_200_response() -> None: + http_client = mock_http_client_response(404, b"not found") + client = HttpCredentialsClient(http_client, retries=1) + + with pytest.raises(SmithyIdentityError) as exc_info: + await client.get_credentials(URI(scheme="http", host="169.254.170.2"), Fields()) + + assert "Container metadata service returned 404" in str(exc_info.value.__cause__) + assert "Failed to retrieve container metadata after 1 attempt(s)" in str( + exc_info.value + ) + + +async def test_client_invalid_json() -> None: + http_client = mock_http_client_response( + 200, b"proxy" + ) + client = HttpCredentialsClient(http_client, retries=1) + + with pytest.raises(SmithyIdentityError): + await client.get_credentials(URI(scheme="http", host="169.254.170.2"), Fields()) + + +async def test_client_retries() -> None: + http_client = AsyncMock() + client = HttpCredentialsClient(http_client, retries=2) + 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 diff --git a/packages/aws-credentials-http/tests/unit/test_providers.py b/packages/aws-credentials-http/tests/unit/test_providers.py new file mode 100644 index 0000000..4531526 --- /dev/null +++ b/packages/aws-credentials-http/tests/unit/test_providers.py @@ -0,0 +1,115 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest +from aws_credentials_http import ContainerCredentialsResolver, EcsContainerProvider +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain import Standard, StandardProvider +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_core.interfaces.identity import Identity + +_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" +_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI" + +_ALL_ENV = ( + _RELATIVE_URI, + _FULL_URI, +) + + +class OtherIdentity(Identity): + """A non-AWS identity type used to verify the provider ignores unknown types.""" + + +@pytest.fixture(autouse=True) +def clear_environment(monkeypatch: pytest.MonkeyPatch) -> None: + # Ensure host environment never leaks container config into the tests. + for name in _ALL_ENV: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def setup_provider() -> Callable[..., Awaitable[ChainSetup]]: + async def _setup( + provider: Any, + *, + identity_type: type[Identity] = AWSCredentialsIdentity, + ) -> ChainSetup: + setup = ChainSetup() + setup.set_current_provider(provider) + await provider.setup(identity_type, setup) + return setup + + return _setup + + +def test_provider_metadata() -> None: + provider = EcsContainerProvider() + + assert provider.name == StandardProvider.ECS_CONTAINER.canonical_name + assert provider.ordering == Standard(slot=StandardProvider.ECS_CONTAINER) + + +async def test_ignores_non_aws_identity_type( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_RELATIVE_URI, "/credentials") + + setup = await setup_provider(EcsContainerProvider(), identity_type=OtherIdentity) + + assert setup.resolvers == () + assert not setup.terminal + + +@pytest.mark.parametrize( + ("relative_uri", "full_uri"), + [ + (None, None), + ("", None), + (None, ""), + ("", ""), + ], +) +async def test_requires_configured_endpoint( + relative_uri: str | None, + full_uri: str | None, + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + if relative_uri is not None: + monkeypatch.setenv(_RELATIVE_URI, relative_uri) + if full_uri is not None: + monkeypatch.setenv(_FULL_URI, full_uri) + + setup = await setup_provider(EcsContainerProvider()) + + assert setup.resolvers == () + assert not setup.terminal + + +@pytest.mark.parametrize( + ("name", "value"), + [ + (_RELATIVE_URI, "/credentials"), + (_FULL_URI, "http://169.254.170.23/credentials"), + ], +) +async def test_registers_terminal_resolver_for_env_vars( + name: str, + value: str, + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(name, value) + + setup = await setup_provider(EcsContainerProvider()) + + assert setup.terminal + assert len(setup.resolvers) == 1 + assert setup.resolvers[0].provider_name == "EcsContainer" + resolver = setup.resolvers[0].resolver + assert isinstance(resolver, ContainerCredentialsResolver) diff --git a/packages/aws-credentials-http/tests/unit/test_resolvers.py b/packages/aws-credentials-http/tests/unit/test_resolvers.py new file mode 100644 index 0000000..5c0345c --- /dev/null +++ b/packages/aws-credentials-http/tests/unit/test_resolvers.py @@ -0,0 +1,237 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import json +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from aws_credentials_http import ContainerCredentialsResolver +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_core import URI +from smithy_core.exceptions import SmithyIdentityError + +DEFAULT_RESPONSE_DATA = { + "AccessKeyId": "akid123", + "SecretAccessKey": "s3cr3t", + "Token": "session_token", +} +ISO8601 = "%Y-%m-%dT%H:%M:%SZ" + + +def mock_http_client_response(status: int, body: bytes) -> AsyncMock: + http_client = AsyncMock() + response = AsyncMock() + response.status = status + response.consume_body_async.return_value = body + http_client.send.return_value = response + return http_client + + +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"] + + +async def test_resolver_env_relative() -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + + with patch.dict( + os.environ, {ContainerCredentialsResolver.ENV_VAR: "/test"}, clear=True + ): + resolver = ContainerCredentialsResolver(http_client) + identity = await resolver.get_identity(properties={}) + + 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) + + +async def test_resolver_env_full() -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + + with patch.dict( + os.environ, + {ContainerCredentialsResolver.ENV_VAR_FULL: "http://169.254.170.23/full"}, + clear=True, + ): + resolver = ContainerCredentialsResolver(http_client) + identity = await resolver.get_identity(properties={}) + + 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) + + +async def test_resolver_env_token() -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + + with patch.dict( + os.environ, + { + ContainerCredentialsResolver.ENV_VAR_FULL: ("http://169.254.170.23/full"), + ContainerCredentialsResolver.ENV_VAR_AUTH_TOKEN: "Bearer foobar", + }, + clear=True, + ): + resolver = ContainerCredentialsResolver(http_client) + identity = await resolver.get_identity(properties={}) + + 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 is not None + assert auth_field.as_string() == "Bearer foobar" + _assert_expected_identity(identity) + + +async def test_resolver_env_token_file(tmp_path: Path) -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + token_file = tmp_path / "token_file" + token_file.write_text("Bearer barfoo") + + with patch.dict( + os.environ, + { + ContainerCredentialsResolver.ENV_VAR_FULL: ("http://169.254.170.23/full"), + ContainerCredentialsResolver.ENV_VAR_AUTH_TOKEN_FILE: str(token_file), + }, + clear=True, + ): + resolver = ContainerCredentialsResolver(http_client) + identity = await resolver.get_identity(properties={}) + + 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 is not None + assert auth_field.as_string() == "Bearer barfoo" + _assert_expected_identity(identity) + + +async def test_resolver_env_token_file_invalid_bytes(tmp_path: Path) -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + token_file = tmp_path / "token_file" + token_file.write_bytes(b"Bearer bar\xff\xfe\xfafoo") + + with patch.dict( + os.environ, + { + ContainerCredentialsResolver.ENV_VAR_FULL: ("http://169.254.170.23/full"), + ContainerCredentialsResolver.ENV_VAR_AUTH_TOKEN_FILE: str(token_file), + }, + clear=True, + ): + resolver = ContainerCredentialsResolver(http_client) + with pytest.raises( + SmithyIdentityError, match="Unable to read valid utf-8 bytes from " + ): + await resolver.get_identity(properties={}) + + +async def test_resolver_env_token_file_precedence(tmp_path: Path) -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + token_file = tmp_path / "token_file" + token_file.write_text("Bearer barfoo") + + with patch.dict( + os.environ, + { + ContainerCredentialsResolver.ENV_VAR_FULL: ("http://169.254.170.23/full"), + ContainerCredentialsResolver.ENV_VAR_AUTH_TOKEN_FILE: str(token_file), + ContainerCredentialsResolver.ENV_VAR_AUTH_TOKEN: "Bearer foobar", + }, + clear=True, + ): + resolver = ContainerCredentialsResolver(http_client) + identity = await resolver.get_identity(properties={}) + + 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 is not None + assert auth_field.as_string() == "Bearer barfoo" + _assert_expected_identity(identity) + + +async def test_resolver_valid_credentials_reused() -> None: + response_data = dict(DEFAULT_RESPONSE_DATA) + expiration = datetime.now(UTC) + timedelta(minutes=10) + response_data["Expiration"] = expiration.strftime(ISO8601) + http_client = mock_http_client_response(200, json.dumps(response_data).encode()) + + with patch.dict( + os.environ, {ContainerCredentialsResolver.ENV_VAR: "/test"}, clear=True + ): + resolver = ContainerCredentialsResolver(http_client) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + _assert_expected_identity(identity_one) + assert identity_one is identity_two + + +async def test_resolver_expired_credentials_refreshed() -> None: + response_data = dict(DEFAULT_RESPONSE_DATA) + expiration = datetime.now(UTC) - timedelta(minutes=10) + response_data["Expiration"] = expiration.strftime(ISO8601) + http_client = mock_http_client_response(200, json.dumps(response_data).encode()) + + with patch.dict( + os.environ, {ContainerCredentialsResolver.ENV_VAR: "/test"}, clear=True + ): + resolver = ContainerCredentialsResolver(http_client) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + _assert_expected_identity(identity_one) + 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 + + +async def test_resolver_missing_env() -> None: + response_body = json.dumps(DEFAULT_RESPONSE_DATA) + http_client = mock_http_client_response(200, response_body.encode()) + + with patch.dict(os.environ, {}, clear=True): + resolver = ContainerCredentialsResolver(http_client) + with pytest.raises(SmithyIdentityError): + await resolver.get_identity(properties={}) diff --git a/packages/aws-credentials-imds/.changes/next-release/aws-credentials-imds-feature-a3999a2d64084b49a4da9d9dea1773a1.json b/packages/aws-credentials-imds/.changes/next-release/aws-credentials-imds-feature-a3999a2d64084b49a4da9d9dea1773a1.json new file mode 100644 index 0000000..65de0e4 --- /dev/null +++ b/packages/aws-credentials-imds/.changes/next-release/aws-credentials-imds-feature-a3999a2d64084b49a4da9d9dea1773a1.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Add EC2 Instance Metadata Service (IMDSv2) credentials resolver and `Ec2InstanceMetadata` chain provider." +} diff --git a/packages/aws-credentials-imds/NOTICE b/packages/aws-credentials-imds/NOTICE new file mode 100644 index 0000000..616fc58 --- /dev/null +++ b/packages/aws-credentials-imds/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/aws-credentials-imds/README.md b/packages/aws-credentials-imds/README.md new file mode 100644 index 0000000..f234833 --- /dev/null +++ b/packages/aws-credentials-imds/README.md @@ -0,0 +1,5 @@ +# aws-credentials-imds + +This package provides an EC2 instance metadata (IMDSv2) credential resolver and chain +provider. Installing it automatically adds the `EC2_INSTANCE_METADATA` source to the +SDK's modular AWS credential chain. diff --git a/packages/aws-credentials-imds/pyproject.toml b/packages/aws-credentials-imds/pyproject.toml new file mode 100644 index 0000000..994d35d --- /dev/null +++ b/packages/aws-credentials-imds/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "aws-credentials-imds" +dynamic = ["version"] +requires-python = ">=3.12" +authors = [ + {name = "Amazon Web Services"}, +] +description = "EC2 Instance Metadata Service credentials support for the AWS SDK for Python." +readme = "README.md" +license = {text = "Apache License 2.0"} +keywords = ["aws", "credentials", "ec2", "imds", "sdk", "smithy"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Free Threading :: 2 - Beta", + "Topic :: Software Development :: Libraries", +] +# TODO: Sync these with latest release before merging +dependencies = [ + "smithy-aws-core~=0.7.0", + "smithy-core~=0.6.0", + "smithy-http[aiohttp]~=0.4.0", +] + +[project.urls] +"Code" = "https://github.com/aws/aws-sdk-python/tree/develop/packages/aws-credentials-imds/" +"Issue tracker" = "https://github.com/aws/aws-sdk-python/issues" + +[project.entry-points."smithy_aws_core.identity.chain_providers"] +Ec2InstanceMetadata = "aws_credentials_imds.providers:Ec2InstanceMetadataProvider" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/aws_credentials_imds/__init__.py" + +[tool.hatch.build] +exclude = [ + "tests", +] + +[tool.ruff] +src = ["src"] diff --git a/packages/aws-credentials-imds/src/aws_credentials_imds/__init__.py b/packages/aws-credentials-imds/src/aws_credentials_imds/__init__.py new file mode 100644 index 0000000..cc0bb81 --- /dev/null +++ b/packages/aws-credentials-imds/src/aws_credentials_imds/__init__.py @@ -0,0 +1,13 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +__version__ = "0.0.0" + +from .client import IMDSConfigurationError +from .providers import Ec2InstanceMetadataProvider +from .resolvers import IMDSCredentialsResolver + +__all__ = ( + "Ec2InstanceMetadataProvider", + "IMDSConfigurationError", + "IMDSCredentialsResolver", +) diff --git a/packages/aws-credentials-imds/src/aws_credentials_imds/client.py b/packages/aws-credentials-imds/src/aws_credentials_imds/client.py new file mode 100644 index 0000000..99be92c --- /dev/null +++ b/packages/aws-credentials-imds/src/aws_credentials_imds/client.py @@ -0,0 +1,178 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta +from types import MappingProxyType +from typing import Literal + +from smithy_core import URI +from smithy_core.aio.interfaces.retries import RetryStrategy +from smithy_core.aio.retries import SimpleRetryStrategy +from smithy_core.exceptions import SmithyError +from smithy_http import Field, Fields +from smithy_http.aio import HTTPRequest +from smithy_http.aio.interfaces import HTTPClient + +from . import __version__ + +_USER_AGENT_FIELD = Field( + name="User-Agent", + values=[f"aws-sdk-python-imds-client/{__version__}"], +) + + +class IMDSConfigurationError(SmithyError): + """Raised when IMDS credential configuration is invalid.""" + + +@dataclass(init=False) +class IMDSConfig: + """Configuration for IMDSClient.""" + + _HOST_MAPPING = MappingProxyType( + {"IPv4": "169.254.169.254", "IPv6": "[fd00:ec2::254]"} + ) + _MIN_TTL = 5 + _MAX_TTL = 21600 + + retry_strategy: RetryStrategy + endpoint_uri: URI + endpoint_mode: Literal["IPv4", "IPv6"] + token_ttl: int + + def __init__( + self, + *, + retry_strategy: RetryStrategy | None = None, + endpoint_uri: URI | None = None, + endpoint_mode: Literal["IPv4", "IPv6"] = "IPv4", + token_ttl: int = _MAX_TTL, + ec2_instance_profile_name: str | None = None, + ): + # TODO: Implement IMDS request retries. + self.retry_strategy = retry_strategy or SimpleRetryStrategy(max_attempts=3) + self.endpoint_mode = endpoint_mode + self.endpoint_uri = self._resolve_endpoint(endpoint_uri, endpoint_mode) + self.token_ttl = self._validate_token_ttl(token_ttl) + self.ec2_instance_profile_name = ec2_instance_profile_name + + def _validate_token_ttl(self, ttl: int) -> int: + if not self._MIN_TTL <= ttl <= self._MAX_TTL: + raise IMDSConfigurationError( + f"Token TTL must be between {self._MIN_TTL} and {self._MAX_TTL} seconds." + ) + return ttl + + def _resolve_endpoint( + self, endpoint_uri: URI | None, endpoint_mode: Literal["IPv4", "IPv6"] + ) -> URI: + if endpoint_uri is not None: + return endpoint_uri + + return URI( + scheme="http", + host=self._HOST_MAPPING.get(endpoint_mode, self._HOST_MAPPING["IPv4"]), + port=80, + ) + + +class IMDSToken: + """Represents an IMDSv2 session token.""" + + def __init__(self, value: str, ttl: int): + self._value = value + self._ttl = ttl + self._created_time = datetime.now() + + def is_expired(self) -> bool: + return datetime.now() - self._created_time >= timedelta(seconds=self._ttl) + + @property + def value(self) -> str: + return self._value + + +class IMDSTokenCache: + """Holds and refreshes the token used to fetch instance metadata.""" + + _TOKEN_PATH = "/latest/api/token" # noqa: S105 + + def __init__(self, http_client: HTTPClient, config: IMDSConfig): + self._http_client = http_client + self._config = config + self._base_uri = config.endpoint_uri + self._refresh_lock = asyncio.Lock() + self._token = None + + def _should_refresh(self) -> bool: + return self._token is None or self._token.is_expired() + + async def _refresh(self) -> None: + async with self._refresh_lock: + if not self._should_refresh(): + return + headers = Fields( + [ + _USER_AGENT_FIELD, + Field( + name="x-aws-ec2-metadata-token-ttl-seconds", + values=[str(self._config.token_ttl)], + ), + ] + ) + request = HTTPRequest( + method="PUT", + destination=URI( + scheme=self._base_uri.scheme, + host=self._base_uri.host, + port=self._base_uri.port, + path=self._TOKEN_PATH, + ), + fields=headers, + ) + response = await self._http_client.send(request) + token_value = await response.consume_body_async() + self._token = IMDSToken(token_value.decode("utf-8"), self._config.token_ttl) + + async def get_token(self) -> IMDSToken: + if self._should_refresh(): + await self._refresh() + assert self._token is not None # noqa: S101 + return self._token + + +class IMDSClient: + """Minimal asynchronous IMDSv2 client.""" + + def __init__(self, http_client: HTTPClient, config: IMDSConfig | None = None): + self._http_client = http_client + self._config = config or IMDSConfig() + self._token_cache = IMDSTokenCache( + http_client=self._http_client, config=self._config + ) + + async def get(self, *, path: str) -> str: + token = await self._token_cache.get_token() + headers = Fields( + [ + _USER_AGENT_FIELD, + Field( + name="x-aws-ec2-metadata-token", + values=[token.value], + ), + ] + ) + request = HTTPRequest( + method="GET", + destination=URI( + scheme=self._config.endpoint_uri.scheme, + host=self._config.endpoint_uri.host, + port=self._config.endpoint_uri.port, + path=path, + ), + fields=headers, + ) + response = await self._http_client.send(request=request) + body = await response.consume_body_async() + return body.decode("utf-8") diff --git a/packages/aws-credentials-imds/src/aws_credentials_imds/providers.py b/packages/aws-credentials-imds/src/aws_credentials_imds/providers.py new file mode 100644 index 0000000..ef1b6d5 --- /dev/null +++ b/packages/aws-credentials-imds/src/aws_credentials_imds/providers.py @@ -0,0 +1,133 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os +from typing import Literal +from urllib.parse import urlsplit + +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain import Standard, StandardProvider +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_core import URI +from smithy_core.interfaces.identity import Identity + +from .client import IMDSConfig, IMDSConfigurationError +from .resolvers import IMDSCredentialsResolver + +_DISABLED_ENV = "AWS_EC2_METADATA_DISABLED" +_DISABLED_PROFILE = "disable_ec2_metadata" +_ENDPOINT_ENV = "AWS_EC2_METADATA_SERVICE_ENDPOINT" +_ENDPOINT_PROFILE = "ec2_metadata_service_endpoint" +_ENDPOINT_MODE_ENV = "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE" +_ENDPOINT_MODE_PROFILE = "ec2_metadata_service_endpoint_mode" +_PROFILE_NAME_ENV = "AWS_EC2_INSTANCE_PROFILE_NAME" +_PROFILE_NAME_PROFILE = "ec2_instance_profile_name" + + +def _profile_value(setup: ChainSetup, key: str) -> str | None: + config_file = setup.config_file + profile_name = setup.profile_name + if config_file is None or profile_name is None: + return None + return config_file.get(profile_name, key) + + +def _resolve_value( + setup: ChainSetup, + env_name: str, + profile_key: str, +) -> str | None: + env_value = os.environ.get(env_name) + if env_value: + return env_value + return _profile_value(setup, profile_key) + + +def _parse_endpoint_uri(value: str | None) -> URI | None: + if value is None: + return None + + try: + parsed = urlsplit(value) + + if not parsed.scheme or parsed.hostname is None: + raise ValueError + + # Access validates the port value and range + port = parsed.port + except ValueError as error: + raise IMDSConfigurationError(f"Invalid IMDS endpoint URI: {value}") from error + + return URI( + scheme=parsed.scheme, + host=parsed.hostname, + port=port, + ) + + +def _parse_endpoint_mode(value: str | None) -> Literal["IPv4", "IPv6"]: + if value is None: + return "IPv4" + + normalized = value.casefold() + if normalized == "ipv4": + return "IPv4" + if normalized == "ipv6": + return "IPv6" + raise IMDSConfigurationError( + f"Invalid IMDS endpoint mode {value!r}; expected 'IPv4' or 'IPv6'." + ) + + +def _validate_profile_name(value: str | None) -> str | None: + if value is not None and not value.strip(): + raise IMDSConfigurationError( + "The configured EC2 instance profile name must not be blank." + ) + return value + + +class Ec2InstanceMetadataProvider: + """Adds an IMDS resolver to the credential chain.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.EC2_INSTANCE_METADATA.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.EC2_INSTANCE_METADATA) + + async def setup( + self, + identity_type: type[Identity], + setup: ChainSetup, + ) -> None: + """Add IMDS as a non-terminal resolver unless disabled.""" + if identity_type is not AWSCredentialsIdentity: + return + + disabled = _resolve_value(setup, _DISABLED_ENV, _DISABLED_PROFILE) + if disabled is not None and disabled.casefold() == "true": + return + + endpoint = _resolve_value(setup, _ENDPOINT_ENV, _ENDPOINT_PROFILE) + endpoint_mode = _resolve_value( + setup, _ENDPOINT_MODE_ENV, _ENDPOINT_MODE_PROFILE + ) + ec2_instance_profile_name = _resolve_value( + setup, _PROFILE_NAME_ENV, _PROFILE_NAME_PROFILE + ) + setup.add_resolver( + IMDSCredentialsResolver( + http_client=setup.http_client, + config=IMDSConfig( + endpoint_uri=_parse_endpoint_uri(endpoint), + endpoint_mode=_parse_endpoint_mode(endpoint_mode), + ec2_instance_profile_name=_validate_profile_name( + ec2_instance_profile_name + ), + ), + ) + ) diff --git a/packages/aws-credentials-imds/src/aws_credentials_imds/py.typed b/packages/aws-credentials-imds/src/aws_credentials_imds/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/aws-credentials-imds/src/aws_credentials_imds/py.typed @@ -0,0 +1 @@ + diff --git a/packages/aws-credentials-imds/src/aws_credentials_imds/resolvers.py b/packages/aws-credentials-imds/src/aws_credentials_imds/resolvers.py new file mode 100644 index 0000000..c05f47c --- /dev/null +++ b/packages/aws-credentials-imds/src/aws_credentials_imds/resolvers.py @@ -0,0 +1,72 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import json +from datetime import UTC, datetime + +from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyIdentityError +from smithy_http.aio.aiohttp import AIOHTTPClient +from smithy_http.aio.interfaces import HTTPClient + +from .client import IMDSClient, IMDSConfig + + +class IMDSCredentialsResolver( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] +): + """Resolves AWS credentials from the EC2 Instance Metadata Service.""" + + _METADATA_PATH_BASE = "/latest/meta-data/iam/security-credentials" + + def __init__( + self, http_client: HTTPClient | None = None, config: IMDSConfig | None = None + ): + self._http_client = http_client or AIOHTTPClient() + self._imds_client = IMDSClient(http_client=self._http_client, config=config) + self._config = config or IMDSConfig() + self._credentials = None + + async def get_identity( + self, *, properties: AWSIdentityProperties + ) -> AWSCredentialsIdentity: + """Return cached credentials if valid, otherwise fetch from IMDS.""" + if ( + self._credentials is not None + and self._credentials.expiration + and datetime.now(UTC) < self._credentials.expiration + ): + return self._credentials + + profile = self._config.ec2_instance_profile_name + if profile is None: + profile = await self._imds_client.get(path=self._METADATA_PATH_BASE) + + creds_str = await self._imds_client.get( + path=f"{self._METADATA_PATH_BASE}/{profile}" + ) + creds = json.loads(creds_str) + + access_key_id = creds.get("AccessKeyId") + secret_access_key = creds.get("SecretAccessKey") + session_token = creds.get("Token") + account_id = creds.get("AccountId") + expiration = creds.get("Expiration") + if expiration is not None: + 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") + + 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 + + async def invalidate(self) -> None: + """Discard cached credentials so the next resolution re-queries IMDS.""" + self._credentials = None diff --git a/packages/aws-credentials-imds/tests/unit/test_client.py b/packages/aws-credentials-imds/tests/unit/test_client.py new file mode 100644 index 0000000..623bc14 --- /dev/null +++ b/packages/aws-credentials-imds/tests/unit/test_client.py @@ -0,0 +1,146 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# pyright: reportPrivateUsage=false +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aws_credentials_imds.client import ( + IMDSClient, + IMDSConfig, + IMDSConfigurationError, + IMDSToken, + IMDSTokenCache, +) +from smithy_core import URI +from smithy_core.aio.retries import SimpleRetryStrategy +from smithy_http.aio import HTTPRequest + + +def test_config_defaults() -> None: + config = IMDSConfig() + assert isinstance(config.retry_strategy, SimpleRetryStrategy) + assert config.endpoint_uri == URI( + scheme="http", host=IMDSConfig._HOST_MAPPING["IPv4"], port=80 + ) + assert config.endpoint_mode == "IPv4" + assert config.token_ttl == 21600 + + +def test_endpoint_resolution() -> None: + config_ipv4 = IMDSConfig(endpoint_mode="IPv4") + config_ipv6 = IMDSConfig(endpoint_mode="IPv6") + assert config_ipv4.endpoint_uri.host == IMDSConfig._HOST_MAPPING["IPv4"] + assert config_ipv6.endpoint_uri.host == IMDSConfig._HOST_MAPPING["IPv6"] + + +def test_config_uses_custom_endpoint() -> None: + # The custom endpoint should take precedence over IPv4 endpoint resolution. + config = IMDSConfig( + endpoint_uri=URI(scheme="https", host="test.host", port=123), + endpoint_mode="IPv4", + ) + assert config.endpoint_uri == URI(scheme="https", host="test.host", port=123) + + # The custom endpoint takes precedence over IPv6 endpoint resolution. + config = IMDSConfig( + endpoint_uri=URI(scheme="https", host="test.host", port=123), + endpoint_mode="IPv6", + ) + assert config.endpoint_uri == URI(scheme="https", host="test.host", port=123) + + +def test_config_ttl_validation() -> None: + # TTL values < _MIN_TTL should raise a configuration error + with pytest.raises(IMDSConfigurationError): + IMDSConfig(token_ttl=IMDSConfig._MIN_TTL - 1) + # TTL values > _MAX_TTL should raise a configuration error + with pytest.raises(IMDSConfigurationError): + IMDSConfig(token_ttl=IMDSConfig._MAX_TTL + 1) + + +def test_token_creation() -> None: + token = IMDSToken(value="test-token", ttl=100) + assert token._value == "test-token" + assert token._ttl == 100 + assert not token.is_expired() + + +def test_token_expiration() -> None: + token = IMDSToken(value="test-token", ttl=1) + assert not token.is_expired() + time.sleep(1.1) + assert token.is_expired() + + +async def test_token_cache_should_refresh() -> None: + http_client = AsyncMock() + config = MagicMock() + # A new token cache needs a refresh + token_cache = IMDSTokenCache(http_client, config) + assert token_cache._should_refresh() + # A token cache with an unexpired token doesn't need a refresh + token_cache._token = MagicMock() + token_cache._token.is_expired.return_value = False + assert not token_cache._should_refresh() + # A token cache with an expired token needs a refresh + token_cache._token.is_expired.return_value = True + assert token_cache._should_refresh() + + +async def test_token_cache_refresh() -> None: + # Test that IMDSTokenCache correctly refreshes the token when needed + http_client = AsyncMock() + config = MagicMock() + config.token_ttl = 100 + config.endpoint_uri.scheme = "http" + config.endpoint_uri.host = "169.254.169.254" + response_mock = AsyncMock() + response_mock.consume_body_async.return_value = b"new-token-value" + http_client.send.return_value = response_mock + token_cache = IMDSTokenCache(http_client, config) + assert token_cache._should_refresh() + await token_cache._refresh() + assert token_cache._token is not None + assert token_cache._token.value == "new-token-value" + assert token_cache._token._ttl == 100 + + +async def test_token_cache_get_token() -> None: + # Test that IMDSTokenCache returns an existing token or refreshes if expired + http_client = AsyncMock() + config = MagicMock() + token_cache = IMDSTokenCache(http_client, config) + token_cache._refresh = AsyncMock() + token_cache._token = MagicMock() + token_cache._token.is_expired.return_value = False + token = await token_cache.get_token() + assert token == token_cache._token + token_cache._refresh.assert_not_awaited() + token_cache._token.is_expired.return_value = True + await token_cache.get_token() + token_cache._refresh.assert_awaited() + + +async def test_imds_client_get() -> None: + # Test IMDSClient.get() method to retrieve metadata from IMDS + http_client = AsyncMock() + config = IMDSConfig() + response = AsyncMock() + response.consume_body_async.return_value = b"metadata-response" + http_client.send.return_value = response + + client = IMDSClient(http_client, config) + client._token_cache.get_token = AsyncMock( + return_value=IMDSToken("mocked-token", config.token_ttl) + ) + + result = await client.get(path="/test-path") + assert result == "metadata-response" + + request = http_client.send.call_args.kwargs["request"] + assert isinstance(request, HTTPRequest) + assert request.destination.path == "/test-path" + assert request.method == "GET" + assert request.fields["x-aws-ec2-metadata-token"].values == ["mocked-token"] diff --git a/packages/aws-credentials-imds/tests/unit/test_providers.py b/packages/aws-credentials-imds/tests/unit/test_providers.py new file mode 100644 index 0000000..d4c8d9b --- /dev/null +++ b/packages/aws-credentials-imds/tests/unit/test_providers.py @@ -0,0 +1,295 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# pyright: reportPrivateUsage=false +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +import pytest +from aws_credentials_imds.client import IMDSConfigurationError +from aws_credentials_imds.providers import Ec2InstanceMetadataProvider +from aws_credentials_imds.resolvers import IMDSCredentialsResolver +from smithy_aws_core.config.file_parser import Section, StandardizedOutput +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain import Standard, StandardProvider +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_core.interfaces.identity import Identity + +_ENDPOINT_ENV = "AWS_EC2_METADATA_SERVICE_ENDPOINT" +_ENDPOINT_MODE_ENV = "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE" +_DISABLED_ENV = "AWS_EC2_METADATA_DISABLED" +_PROFILE_NAME_ENV = "AWS_EC2_INSTANCE_PROFILE_NAME" + +_ALL_ENV = ( + _ENDPOINT_ENV, + _ENDPOINT_MODE_ENV, + _DISABLED_ENV, + _PROFILE_NAME_ENV, +) + + +class OtherIdentity(Identity): + """A non-AWS identity type used to verify the provider ignores unknown types.""" + + +@pytest.fixture(autouse=True) +def clear_environment(monkeypatch: pytest.MonkeyPatch) -> None: + # Ensure host environment never leaks IMDS config into the tests. + for name in _ALL_ENV: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def merged_config() -> Callable[..., MergedConfig]: + def _build( + profiles: Mapping[str, Mapping[str, str]] | None = None, + ) -> MergedConfig: + sections = { + name: Section(properties=dict(properties)) + for name, properties in (profiles or {}).items() + } + return MergedConfig(StandardizedOutput(profiles=sections), StandardizedOutput()) + + return _build + + +@pytest.fixture +def setup_provider() -> Callable[..., Awaitable[ChainSetup]]: + async def _setup( + provider: Any, + *, + identity_type: type[Identity] = AWSCredentialsIdentity, + config_file: MergedConfig | None = None, + profile_name: str | None = "default", + ) -> ChainSetup: + setup = ChainSetup(config_file=config_file, profile_name=profile_name) + setup.set_current_provider(provider) + await provider.setup(identity_type, setup) + return setup + + return _setup + + +def _only_resolver(setup: ChainSetup) -> IMDSCredentialsResolver: + assert len(setup.resolvers) == 1 + resolver = setup.resolvers[0].resolver + assert isinstance(resolver, IMDSCredentialsResolver) + return resolver + + +def test_provider_metadata() -> None: + provider = Ec2InstanceMetadataProvider() + + assert provider.name == StandardProvider.EC2_INSTANCE_METADATA.canonical_name + assert provider.ordering == Standard(slot=StandardProvider.EC2_INSTANCE_METADATA) + + +async def test_ignores_non_aws_identity_type( + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider( + Ec2InstanceMetadataProvider(), identity_type=OtherIdentity + ) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_registers_non_terminal_resolver( + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + assert not setup.terminal + assert len(setup.resolvers) == 1 + assert setup.resolvers[0].provider_name == ( + StandardProvider.EC2_INSTANCE_METADATA.canonical_name + ) + assert isinstance(setup.resolvers[0].resolver, IMDSCredentialsResolver) + + +@pytest.mark.parametrize("value", ["true", "True", "TRUE"]) +async def test_disabled_env_skips_registration( + value: str, + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_DISABLED_ENV, value) + + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_disabled_profile_skips_registration( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config = merged_config({"default": {"disable_ec2_metadata": "true"}}) + + setup = await setup_provider( + Ec2InstanceMetadataProvider(), config_file=config, profile_name="default" + ) + + assert setup.resolvers == () + + +async def test_disabled_false_still_registers( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_DISABLED_ENV, "false") + + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + assert len(setup.resolvers) == 1 + + +async def test_endpoint_mode_from_env( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_ENDPOINT_MODE_ENV, "IPv6") + + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + config = _only_resolver(setup)._config + assert config.endpoint_mode == "IPv6" + assert config.endpoint_uri.host == config._HOST_MAPPING["IPv6"] + + +async def test_endpoint_mode_is_case_insensitive( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_ENDPOINT_MODE_ENV, "ipv6") + + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + assert _only_resolver(setup)._config.endpoint_mode == "IPv6" + + +async def test_endpoint_mode_env_overrides_profile( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_ENDPOINT_MODE_ENV, "IPv6") + config = merged_config({"default": {"ec2_metadata_service_endpoint_mode": "IPv4"}}) + + setup = await setup_provider(Ec2InstanceMetadataProvider(), config_file=config) + + assert _only_resolver(setup)._config.endpoint_mode == "IPv6" + + +async def test_endpoint_mode_from_profile( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config = merged_config({"default": {"ec2_metadata_service_endpoint_mode": "IPv6"}}) + + setup = await setup_provider(Ec2InstanceMetadataProvider(), config_file=config) + + assert _only_resolver(setup)._config.endpoint_mode == "IPv6" + + +async def test_invalid_endpoint_mode_raises( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_ENDPOINT_MODE_ENV, "IPv5") + + with pytest.raises( + IMDSConfigurationError, match="Invalid IMDS endpoint mode 'IPv5'" + ): + await setup_provider(Ec2InstanceMetadataProvider()) + + +async def test_endpoint_from_env( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_ENDPOINT_ENV, "http://169.254.169.200:8080") + + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + endpoint = _only_resolver(setup)._config.endpoint_uri + assert endpoint.scheme == "http" + assert endpoint.host == "169.254.169.200" + assert endpoint.port == 8080 + + +async def test_endpoint_env_overrides_profile( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_ENDPOINT_ENV, "http://169.254.169.200") + config = merged_config( + {"default": {"ec2_metadata_service_endpoint": "http://169.254.169.111"}} + ) + + setup = await setup_provider(Ec2InstanceMetadataProvider(), config_file=config) + + assert _only_resolver(setup)._config.endpoint_uri.host == "169.254.169.200" + + +async def test_endpoint_from_profile( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config = merged_config( + {"default": {"ec2_metadata_service_endpoint": "http://169.254.169.111"}} + ) + + setup = await setup_provider(Ec2InstanceMetadataProvider(), config_file=config) + + assert _only_resolver(setup)._config.endpoint_uri.host == "169.254.169.111" + + +async def test_invalid_endpoint_raises( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Missing scheme is not a valid endpoint URI. + monkeypatch.setenv(_ENDPOINT_ENV, "169.254.169.254") + + with pytest.raises(IMDSConfigurationError, match="Invalid IMDS endpoint URI"): + await setup_provider(Ec2InstanceMetadataProvider()) + + +async def test_profile_name_from_env( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_PROFILE_NAME_ENV, "my-profile") + + setup = await setup_provider(Ec2InstanceMetadataProvider()) + + assert _only_resolver(setup)._config.ec2_instance_profile_name == "my-profile" + + +async def test_profile_name_from_profile( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config = merged_config({"default": {"ec2_instance_profile_name": "my-profile"}}) + + setup = await setup_provider(Ec2InstanceMetadataProvider(), config_file=config) + + assert _only_resolver(setup)._config.ec2_instance_profile_name == "my-profile" + + +async def test_blank_profile_name_raises( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_PROFILE_NAME_ENV, " ") + + with pytest.raises( + IMDSConfigurationError, + match="The configured EC2 instance profile name must not be blank", + ): + await setup_provider(Ec2InstanceMetadataProvider()) diff --git a/packages/aws-credentials-imds/tests/unit/test_resolvers.py b/packages/aws-credentials-imds/tests/unit/test_resolvers.py new file mode 100644 index 0000000..949df98 --- /dev/null +++ b/packages/aws-credentials-imds/tests/unit/test_resolvers.py @@ -0,0 +1,145 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# pyright: reportPrivateUsage=false +import json +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest +from aws_credentials_imds.client import IMDSConfig +from aws_credentials_imds.resolvers import IMDSCredentialsResolver +from smithy_core.exceptions import SmithyIdentityError + +ISO8601 = "%Y-%m-%dT%H:%M:%SZ" + +_CREDS = { + "AccessKeyId": "test-access-key", + "SecretAccessKey": "test-secret-key", + "Token": "test-session-token", + "AccountId": "test-account", + "Expiration": "2025-03-13T07:28:47Z", +} + + +async def test_resolver_success() -> None: + http_client = AsyncMock() + config = IMDSConfig() + imds_client = AsyncMock() + resolver = IMDSCredentialsResolver(http_client, config) + resolver._imds_client = imds_client + + imds_client.get.side_effect = ["test-profile", json.dumps(_CREDS)] + + credentials = await resolver.get_identity(properties={}) + assert credentials.access_key_id == "test-access-key" + assert credentials.secret_access_key == "test-secret-key" + assert credentials.session_token == "test-session-token" + assert credentials.account_id == "test-account" + assert credentials.expiration == datetime(2025, 3, 13, 7, 28, 47, tzinfo=UTC) + imds_client.get.assert_awaited() + + +async def test_resolver_uses_configured_profile_name() -> None: + http_client = AsyncMock() + config = IMDSConfig(ec2_instance_profile_name="configured-profile") + imds_client = AsyncMock() + resolver = IMDSCredentialsResolver(http_client, config) + resolver._imds_client = imds_client + + imds_client.get.return_value = json.dumps(_CREDS) + + await resolver.get_identity(properties={}) + + # No profile lookup call when profile name is configured + imds_client.get.assert_awaited_once_with( + path="/latest/meta-data/iam/security-credentials/configured-profile" + ) + + +async def test_resolver_caches_credentials() -> None: + http_client = AsyncMock() + config = IMDSConfig() + imds_client = AsyncMock() + resolver = IMDSCredentialsResolver(http_client, config) + resolver._imds_client = imds_client + + future = (datetime.now(UTC) + timedelta(minutes=10)).strftime(ISO8601) + imds_client.get.side_effect = [ + "test-profile", + json.dumps({**_CREDS, "Expiration": future}), + ] + + first = await resolver.get_identity(properties={}) + second = await resolver.get_identity(properties={}) + + assert first is second + # Initial call for profile name, second call for credentials + assert imds_client.get.await_count == 2 + + +async def test_resolver_refreshes_expired_credentials() -> None: + http_client = AsyncMock() + config = IMDSConfig() + imds_client = AsyncMock() + resolver = IMDSCredentialsResolver(http_client, config) + resolver._imds_client = imds_client + + past = (datetime.now(UTC) - timedelta(minutes=10)).strftime(ISO8601) + future = (datetime.now(UTC) + timedelta(minutes=10)).strftime(ISO8601) + imds_client.get.side_effect = [ + "test-profile", + json.dumps({**_CREDS, "AccessKeyId": "expired-key", "Expiration": past}), + "test-profile", + json.dumps({**_CREDS, "AccessKeyId": "fresh-key", "Expiration": future}), + ] + + first = await resolver.get_identity(properties={}) + second = await resolver.get_identity(properties={}) + + assert first is not second + assert first.access_key_id == "expired-key" + assert second.access_key_id == "fresh-key" + # Both the profile lookup and credential fetch run again on refresh + assert imds_client.get.await_count == 4 + + +async def test_resolver_invalidate_forces_refresh() -> None: + http_client = AsyncMock() + config = IMDSConfig() + imds_client = AsyncMock() + resolver = IMDSCredentialsResolver(http_client, config) + resolver._imds_client = imds_client + + future = (datetime.now(UTC) + timedelta(minutes=10)).strftime(ISO8601) + imds_client.get.side_effect = [ + "test-profile", + json.dumps({**_CREDS, "Expiration": future}), + "test-profile", + json.dumps({**_CREDS, "Expiration": future}), + ] + + await resolver.get_identity(properties={}) + await resolver.invalidate() + await resolver.get_identity(properties={}) + + # Both the profile lookup and the credential fetch run again after invalidate + assert imds_client.get.await_count == 4 + + +async def test_resolver_requires_access_key_and_secret() -> None: + http_client = AsyncMock() + config = IMDSConfig() + imds_client = AsyncMock() + resolver = IMDSCredentialsResolver(http_client, config) + resolver._imds_client = imds_client + + imds_client.get.side_effect = [ + "test-profile", + json.dumps({"AccessKeyId": "test-access-key"}), + ] + + with pytest.raises( + SmithyIdentityError, match="AccessKeyId and SecretAccessKey are required" + ): + await resolver.get_identity(properties={}) diff --git a/packages/aws-credentials-sts/.changes/next-release/aws-credentials-sts-feature-31813506190e4e598cd79e3271662d6a.json b/packages/aws-credentials-sts/.changes/next-release/aws-credentials-sts-feature-31813506190e4e598cd79e3271662d6a.json new file mode 100644 index 0000000..8030446 --- /dev/null +++ b/packages/aws-credentials-sts/.changes/next-release/aws-credentials-sts-feature-31813506190e4e598cd79e3271662d6a.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Add STS AssumeRole credential resolvers and `ProfileAssumeRole` chain provider." +} diff --git a/packages/aws-credentials-sts/NOTICE b/packages/aws-credentials-sts/NOTICE new file mode 100644 index 0000000..616fc58 --- /dev/null +++ b/packages/aws-credentials-sts/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/aws-credentials-sts/README.md b/packages/aws-credentials-sts/README.md new file mode 100644 index 0000000..28b61bb --- /dev/null +++ b/packages/aws-credentials-sts/README.md @@ -0,0 +1,5 @@ +# aws-credentials-sts + +This package provides STS-based credential resolvers and chain providers. +Installing it automatically adds the `PROFILE_ASSUME_ROLE` source to the SDK's +modular AWS credential chain. diff --git a/packages/aws-credentials-sts/pyproject.toml b/packages/aws-credentials-sts/pyproject.toml new file mode 100644 index 0000000..0abb713 --- /dev/null +++ b/packages/aws-credentials-sts/pyproject.toml @@ -0,0 +1,57 @@ +[project] +name = "aws-credentials-sts" +dynamic = ["version"] +requires-python = ">=3.12" +authors = [ + {name = "Amazon Web Services"}, +] +description = "STS-based credentials support for the AWS SDK for Python." +readme = "README.md" +license = {text = "Apache License 2.0"} +keywords = ["aws", "credentials", "sts", "sdk", "smithy"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Free Threading :: 2 - Beta", + "Topic :: Software Development :: Libraries", +] +# TODO: Sync these with latest release before merging +dependencies = [ + "aws-sdk-sts~=0.1.0", + "smithy-aws-core~=0.7.0", + "smithy-core~=0.6.0", + "smithy-http~=0.4.0", +] + +[project.urls] +"Code" = "https://github.com/aws/aws-sdk-python/tree/develop/packages/aws-credentials-sts/" +"Issue tracker" = "https://github.com/aws/aws-sdk-python/issues" + +[project.entry-points."smithy_aws_core.identity.chain_providers"] +ProfileAssumeRole = "aws_credentials_sts.providers:ProfileAssumeRoleProvider" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/aws_credentials_sts/__init__.py" + +[tool.hatch.build] +exclude = [ + "tests", +] + +[tool.ruff] +src = ["src"] diff --git a/packages/aws-credentials-sts/src/aws_credentials_sts/__init__.py b/packages/aws-credentials-sts/src/aws_credentials_sts/__init__.py new file mode 100644 index 0000000..2dfce19 --- /dev/null +++ b/packages/aws-credentials-sts/src/aws_credentials_sts/__init__.py @@ -0,0 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +__version__ = "0.0.0" + +from .providers import ProfileAssumeRoleProvider +from .resolvers import ( + AssumeRoleConfigurationError, + AssumeRoleCredentialsResolver, + ProfileAssumeRoleCredentialsResolver, +) + +__all__ = ( + "AssumeRoleConfigurationError", + "AssumeRoleCredentialsResolver", + "ProfileAssumeRoleCredentialsResolver", + "ProfileAssumeRoleProvider", +) diff --git a/packages/aws-credentials-sts/src/aws_credentials_sts/providers.py b/packages/aws-credentials-sts/src/aws_credentials_sts/providers.py new file mode 100644 index 0000000..19c7335 --- /dev/null +++ b/packages/aws-credentials-sts/src/aws_credentials_sts/providers.py @@ -0,0 +1,47 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain import ChainSetup, Standard, StandardProvider +from smithy_core.interfaces.identity import Identity + +from .resolvers import ProfileAssumeRoleCredentialsResolver + +_ROLE_ARN = "role_arn" + + +class ProfileAssumeRoleProvider: + """Adds an STS AssumeRole resolver to the credential chain.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.PROFILE_ASSUME_ROLE.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.PROFILE_ASSUME_ROLE) + + async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: + """Add a terminal resolver when the active profile declares a role ARN.""" + if identity_type is not AWSCredentialsIdentity: + return + + profile_name = setup.profile_name + config_file = setup.config_file + if ( + profile_name is None + or config_file is None + or config_file.get(profile_name, _ROLE_ARN) is None + ): + return + + setup.add_terminal_resolver( + ProfileAssumeRoleCredentialsResolver( + profile_name=profile_name, + config_file=config_file, + region_override=setup.region_override, + http_client=setup.http_client, + ) + ) diff --git a/packages/aws-credentials-sts/src/aws_credentials_sts/py.typed b/packages/aws-credentials-sts/src/aws_credentials_sts/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/aws-credentials-sts/src/aws_credentials_sts/py.typed @@ -0,0 +1 @@ + diff --git a/packages/aws-credentials-sts/src/aws_credentials_sts/resolvers.py b/packages/aws-credentials-sts/src/aws_credentials_sts/resolvers.py new file mode 100644 index 0000000..f59df8c --- /dev/null +++ b/packages/aws-credentials-sts/src/aws_credentials_sts/resolvers.py @@ -0,0 +1,367 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import os +import uuid +from collections.abc import Callable +from importlib import metadata +from typing import TYPE_CHECKING, cast + +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity import ( + AWSCredentialsIdentity, + AWSCredentialsResolver, + AWSIdentityProperties, + StaticCredentialsResolver, +) +from smithy_aws_core.identity.chain import ( + ChainIdentityProvider, + ChainSetup, + StandardProvider, +) +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyError, SmithyIdentityError +from smithy_http.aio.interfaces import HTTPClient + +if TYPE_CHECKING: + from aws_sdk_sts.client import AsyncSTSClient + +_CHAIN_PROVIDER_ENTRY_POINT_GROUP = "smithy_aws_core.identity.chain_providers" +_DEFAULT_STS_REGION = "us-east-1" + +_ACCESS_KEY_ID = "aws_access_key_id" +_SECRET_ACCESS_KEY = "aws_secret_access_key" # noqa: S105 +_SESSION_TOKEN = "aws_session_token" # noqa: S105 +_ACCOUNT_ID = "aws_account_id" +_ROLE_ARN = "role_arn" +_ROLE_SESSION_NAME = "role_session_name" +_EXTERNAL_ID = "external_id" +_SOURCE_PROFILE = "source_profile" +_CREDENTIAL_SOURCE = "credential_source" +_REGION = "region" + +_CREDENTIAL_SOURCE_SLOTS = { + "Environment": StandardProvider.ENVIRONMENT, + "EcsContainer": StandardProvider.ECS_CONTAINER, + "Ec2InstanceMetadata": StandardProvider.EC2_INSTANCE_METADATA, +} + + +class AssumeRoleConfigurationError(SmithyError): + """Raised when AssumeRole credential configuration is invalid.""" + + +def _account_id_from_arn(arn: str | None) -> str | None: + if arn is None: + return None + parts = arn.split(":") + return parts[4] if len(parts) >= 5 and parts[4] else None + + +def _resolve_sts_region( + *, + config_file: MergedConfig | None = None, + profile_name: str | None = None, +) -> str: + profile_region = ( + config_file.get(profile_name, _REGION) + if config_file is not None and profile_name is not None + else None + ) + return ( + os.getenv("AWS_REGION") + or os.getenv("AWS_DEFAULT_REGION") + or profile_region + or _DEFAULT_STS_REGION + ) + + +class AssumeRoleCredentialsResolver( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] +): + """Resolves credentials with an STS AssumeRole call.""" + + def __init__( + self, + *, + source_resolver: AWSCredentialsResolver, + role_arn: str, + role_session_name: str | None = None, + external_id: str | None = None, + region: str | None = None, + http_client: HTTPClient | None = None, + ) -> None: + self._source_resolver = source_resolver + self._role_arn = role_arn + self._role_session_name = ( + role_session_name or f"aws-sdk-python-{uuid.uuid4().hex[:16]}" + ) + self._external_id = external_id + self._region = region or _DEFAULT_STS_REGION + self._http_client = http_client + self._credentials: AWSCredentialsIdentity | None = None + self._client: AsyncSTSClient | None = None + self._refresh_lock = asyncio.Lock() + + async def get_identity( + self, + *, + properties: AWSIdentityProperties, + ) -> AWSCredentialsIdentity: + """Return cached credentials if valid, otherwise call STS AssumeRole.""" + if self._credentials is not None and not self._credentials.is_expired: + return self._credentials + + async with self._refresh_lock: + if self._credentials is not None and not self._credentials.is_expired: + return self._credentials + self._credentials = await self._assume_role() + return self._credentials + + async def invalidate(self) -> None: + """Discard assumed credentials and invalidate the source resolver.""" + async with self._refresh_lock: + self._credentials = None + await self._source_resolver.invalidate() + + async def _assume_role(self) -> AWSCredentialsIdentity: + from aws_sdk_sts.client import AsyncSTSClient + from aws_sdk_sts.config import Config + from aws_sdk_sts.models import AssumeRoleInput + + if self._client is None: + self._client = AsyncSTSClient( + config=Config( + aws_credentials_identity_resolver=self._source_resolver, + region=self._region, + transport=self._http_client, + ) + ) + + response = await self._client.assume_role( + AssumeRoleInput( + role_arn=self._role_arn, + role_session_name=self._role_session_name, + external_id=self._external_id, + ) + ) + + credentials = response.credentials + if credentials is None: + raise SmithyIdentityError( + "STS AssumeRole response did not contain credentials." + ) + + assumed_role_arn = ( + response.assumed_role_user.arn + if response.assumed_role_user is not None + else None + ) + return AWSCredentialsIdentity( + access_key_id=credentials.access_key_id, + secret_access_key=credentials.secret_access_key, + session_token=credentials.session_token, + expiration=credentials.expiration, + account_id=_account_id_from_arn(assumed_role_arn), + ) + + +class ProfileAssumeRoleCredentialsResolver( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] +): + """Resolves credentials from a profile-based AssumeRole configuration.""" + + def __init__( + self, + *, + profile_name: str, + config_file: MergedConfig, + region_override: str | None = None, + http_client: HTTPClient | None = None, + ) -> None: + if config_file.get_profile(profile_name) is None: + raise AssumeRoleConfigurationError( + f"Profile '{profile_name}' does not exist." + ) + self._profile_name = profile_name + self._config_file = config_file + self._region = region_override or _resolve_sts_region( + config_file=config_file, profile_name=profile_name + ) + self._http_client = http_client + self._delegate: AssumeRoleCredentialsResolver | None = None + self._setup_lock = asyncio.Lock() + + async def get_identity( + self, + *, + properties: AWSIdentityProperties, + ) -> AWSCredentialsIdentity: + """Create delegate resolver if needed, then fetch assume role credentials.""" + if self._delegate is None: + async with self._setup_lock: + if self._delegate is None: + self._delegate = await self._create_assume_role_resolver( + profile_name=self._profile_name, + visited=(self._profile_name,), + ) + return await self._delegate.get_identity(properties=properties) + + async def invalidate(self) -> None: + """Invalidate assumed credentials if resolution has been initialized.""" + if self._delegate is not None: + await self._delegate.invalidate() + + async def _create_assume_role_resolver( + self, + *, + profile_name: str, + visited: tuple[str, ...], + ) -> AssumeRoleCredentialsResolver: + config_file = self._config_file + role_arn = config_file.get(profile_name, _ROLE_ARN) + if role_arn is None: + raise AssumeRoleConfigurationError( + f"Profile '{profile_name}' does not define role_arn." + ) + + source_profile = config_file.get(profile_name, _SOURCE_PROFILE) + credential_source = config_file.get(profile_name, _CREDENTIAL_SOURCE) + if source_profile is not None and credential_source is not None: + raise AssumeRoleConfigurationError( + f"Profile '{profile_name}' cannot define both 'source_profile' and 'credential_source'." + ) + elif source_profile is not None: + source_resolver = await self._create_resolver_from_source_profile( + source_profile, + visited, + ) + elif credential_source is not None: + source_resolver = await self._create_resolver_from_credential_source( + credential_source, + self._region, + ) + else: + raise AssumeRoleConfigurationError( + f"Profile '{profile_name}' must define either 'source_profile' or 'credential_source'." + ) + + return AssumeRoleCredentialsResolver( + source_resolver=source_resolver, + role_arn=role_arn, + role_session_name=config_file.get(profile_name, _ROLE_SESSION_NAME), + external_id=config_file.get(profile_name, _EXTERNAL_ID), + region=self._region, + http_client=self._http_client, + ) + + async def _create_resolver_from_source_profile( + self, + source_profile_name: str, + visited: tuple[str, ...], + ) -> AWSCredentialsResolver: + config_file = self._config_file + is_direct_self_reference = ( + len(visited) > 0 and source_profile_name == visited[-1] + ) + if source_profile_name in visited and not is_direct_self_reference: + path = " -> ".join((*visited, source_profile_name)) + raise AssumeRoleConfigurationError( + f"Circular source_profile reference: {path}." + ) + + if config_file.get_profile(source_profile_name) is None: + raise AssumeRoleConfigurationError( + f"Source profile '{source_profile_name}' does not exist." + ) + + if any( + config_file.get(source_profile_name, key) is not None + for key in (_ACCESS_KEY_ID, _SECRET_ACCESS_KEY, _SESSION_TOKEN) + ): + return self._create_static_resolver(source_profile_name) + + if is_direct_self_reference: + raise AssumeRoleConfigurationError( + f"Self-referencing profile '{source_profile_name}' must contain " + "complete static credentials." + ) + + if config_file.get(source_profile_name, _ROLE_ARN) is not None: + return await self._create_assume_role_resolver( + profile_name=source_profile_name, + visited=(*visited, source_profile_name), + ) + + raise AssumeRoleConfigurationError( + f"Source profile '{source_profile_name}' has no supported credential source." + ) + + def _create_static_resolver( + self, + profile_name: str, + ) -> AWSCredentialsResolver: + config_file = self._config_file + access_key_id = config_file.get(profile_name, _ACCESS_KEY_ID) + secret_access_key = config_file.get(profile_name, _SECRET_ACCESS_KEY) + if access_key_id is None or secret_access_key is None: + raise AssumeRoleConfigurationError( + f"Profile '{profile_name}' contains partial credentials." + ) + + return StaticCredentialsResolver( + AWSCredentialsIdentity( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + session_token=config_file.get(profile_name, _SESSION_TOKEN), + account_id=config_file.get(profile_name, _ACCOUNT_ID), + ) + ) + + async def _create_resolver_from_credential_source( + self, + credential_source: str, + region: str, + ) -> AWSCredentialsResolver: + slot = _CREDENTIAL_SOURCE_SLOTS.get(credential_source) + if slot is None: + raise AssumeRoleConfigurationError( + f"Unsupported 'credential_source': '{credential_source}'." + ) + + provider = self._find_provider(slot) + if provider is None: + raise AssumeRoleConfigurationError( + f"No provider is installed for credential source '{credential_source}'. Install '{slot.module_suggestion}'." + ) + + setup = ChainSetup( + region_override=region, + http_client=self._http_client, + ) + setup.set_current_provider(provider) + await provider.setup(AWSCredentialsIdentity, setup) + if not setup.resolvers: + raise AssumeRoleConfigurationError( + f"'{credential_source}' credential source is not configured." + ) + return cast(AWSCredentialsResolver, setup.resolvers[0]) + + def _find_provider( + self, + slot: StandardProvider, + ) -> ChainIdentityProvider | None: + for entry_point in metadata.entry_points( + group=_CHAIN_PROVIDER_ENTRY_POINT_GROUP + ): + if entry_point.name != slot.canonical_name: + continue + provider_factory = cast( + Callable[[], ChainIdentityProvider], + entry_point.load(), + ) + return provider_factory() + return None diff --git a/packages/aws-credentials-sts/tests/unit/test_providers.py b/packages/aws-credentials-sts/tests/unit/test_providers.py new file mode 100644 index 0000000..ccf0a79 --- /dev/null +++ b/packages/aws-credentials-sts/tests/unit/test_providers.py @@ -0,0 +1,118 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +import pytest +from aws_credentials_sts.providers import ProfileAssumeRoleProvider +from aws_credentials_sts.resolvers import ProfileAssumeRoleCredentialsResolver +from smithy_aws_core.config.file_parser import Section, StandardizedOutput +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain import ChainSetup, Standard, StandardProvider +from smithy_core.interfaces.identity import Identity + +ROLE_ARN = "arn:aws:iam::123456789012:role/MyRole" + + +class OtherIdentity(Identity): + """A non-AWS identity type used to verify the provider ignores unknown types.""" + + +@pytest.fixture +def merged_config() -> Callable[..., MergedConfig]: + def _build( + profiles: Mapping[str, Mapping[str, str]] | None = None, + ) -> MergedConfig: + sections = { + name: Section(properties=dict(properties)) + for name, properties in (profiles or {}).items() + } + return MergedConfig(StandardizedOutput(profiles=sections), StandardizedOutput()) + + return _build + + +@pytest.fixture +def setup_provider() -> Callable[..., Awaitable[ChainSetup]]: + async def _setup( + provider: Any, + *, + identity_type: type[Identity] = AWSCredentialsIdentity, + config_file: MergedConfig | None = None, + profile_name: str, + ) -> ChainSetup: + setup = ChainSetup(config_file=config_file, profile_name=profile_name) + setup.set_current_provider(provider) + await provider.setup(identity_type, setup) + return setup + + return _setup + + +def test_provider_metadata() -> None: + provider = ProfileAssumeRoleProvider() + + assert provider.name == StandardProvider.PROFILE_ASSUME_ROLE.canonical_name + assert provider.ordering == Standard(slot=StandardProvider.PROFILE_ASSUME_ROLE) + + +async def test_ignores_non_aws_identity_type( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config({"default": {"role_arn": ROLE_ARN}}) + setup = await setup_provider( + ProfileAssumeRoleProvider(), + identity_type=OtherIdentity, + config_file=config_file, + profile_name="default", + ) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_no_profile_name_skips( + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(ProfileAssumeRoleProvider(), profile_name=None) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_profile_without_role_arn_skips( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config({"default": {"region": "us-east-1"}}) + setup = await setup_provider( + ProfileAssumeRoleProvider(), config_file=config_file, profile_name="default" + ) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_registers_terminal_resolver_for_role_arn( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "default": {"role_arn": ROLE_ARN, "source_profile": "base"}, + "base": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + }, + } + ) + setup = await setup_provider( + ProfileAssumeRoleProvider(), config_file=config_file, profile_name="default" + ) + + assert setup.terminal + assert len(setup.resolvers) == 1 + assert isinstance(setup.resolvers[0].resolver, ProfileAssumeRoleCredentialsResolver) diff --git a/packages/aws-credentials-sts/tests/unit/test_resolvers.py b/packages/aws-credentials-sts/tests/unit/test_resolvers.py new file mode 100644 index 0000000..d62f156 --- /dev/null +++ b/packages/aws-credentials-sts/tests/unit/test_resolvers.py @@ -0,0 +1,789 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# pyright: reportPrivateUsage=false +from collections.abc import Callable, Mapping +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest +from aws_credentials_sts.resolvers import ( + AssumeRoleConfigurationError, + AssumeRoleCredentialsResolver, + ProfileAssumeRoleCredentialsResolver, + _account_id_from_arn, + _resolve_sts_region, +) +from aws_sdk_sts.models import ( + AssumedRoleUser, + AssumeRoleOutput, + Credentials, +) +from smithy_aws_core.config.file_parser import Section, StandardizedOutput +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity import ( + AWSCredentialsIdentity, + StaticCredentialsResolver, +) +from smithy_aws_core.identity.chain import ( + ChainSetup, + Standard, + StandardProvider, +) +from smithy_aws_core.identity.chain.provider import NamedResolver +from smithy_core.exceptions import SmithyIdentityError + +ROLE_ARN = "arn:aws:iam::123456789012:role/MyRole" +SOURCE_ROLE_ARN = "arn:aws:iam::123456789012:role/SourceRole" +ASSUMED_ROLE_ARN = "arn:aws:sts::123456789012:assumed-role/MyRole/session" +ACCESS_KEY_ID = "test-access-key" +SECRET_ACCESS_KEY = "test-secret-key" +SESSION_TOKEN = "test-session-token" + + +class _FakeProvider: + """A chain provider that adds a single static resolver during setup.""" + + def __init__(self, resolver: StaticCredentialsResolver) -> None: + self._resolver = resolver + + @property + def name(self) -> str: + return StandardProvider.ENVIRONMENT.canonical_name + + @property + def ordering(self) -> Standard: + return Standard(slot=StandardProvider.ENVIRONMENT) + + async def setup(self, identity_type: object, setup: ChainSetup) -> None: + setup.add_resolver(self._resolver) + + +@pytest.fixture +def merged_config() -> Callable[..., MergedConfig]: + def _build(profiles: Mapping[str, Mapping[str, str]]) -> MergedConfig: + sections = { + name: Section(properties=dict(properties)) + for name, properties in profiles.items() + } + return MergedConfig(StandardizedOutput(profiles=sections), StandardizedOutput()) + + return _build + + +def _future_expiry() -> datetime: + return datetime.now(UTC) + timedelta(hours=1) + + +def _past_expiry() -> datetime: + return datetime.now(UTC) - timedelta(hours=1) + + +def _valid_output( + *, access_key_id: str = ACCESS_KEY_ID, expiration: datetime | None = None +) -> AssumeRoleOutput: + """An AssumeRole response with valid credentials and assumed-role user.""" + return AssumeRoleOutput( + credentials=Credentials( + access_key_id=access_key_id, + secret_access_key=SECRET_ACCESS_KEY, + session_token=SESSION_TOKEN, + expiration=expiration or _future_expiry(), + ), + assumed_role_user=AssumedRoleUser(assumed_role_id="id", arn=ASSUMED_ROLE_ARN), + ) + + +def _mock_sts_client( + resolver: AssumeRoleCredentialsResolver, *responses: AssumeRoleOutput +) -> AsyncMock: + """Attach a mock STS client returning one response per AssumeRole call.""" + client = AsyncMock() + client.assume_role.side_effect = list(responses) + resolver._client = client + return client + + +@pytest.mark.parametrize( + ("env_aws_region", "env_aws_default_region", "profile_region", "expected"), + [ + (None, None, None, "us-east-1"), + ("us-west-2", None, "eu-west-1", "us-west-2"), + (None, "ap-south-1", None, "ap-south-1"), + (None, None, "eu-west-1", "eu-west-1"), + ], +) +def test_resolve_sts_region( + env_aws_region: str | None, + env_aws_default_region: str | None, + profile_region: str | None, + expected: str, + monkeypatch: pytest.MonkeyPatch, + merged_config: Callable[..., MergedConfig], +) -> None: + for name, value in ( + ("AWS_REGION", env_aws_region), + ("AWS_DEFAULT_REGION", env_aws_default_region), + ): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + + config_file = merged_config( + {"default": {"region": profile_region}} if profile_region else {} + ) + + region = _resolve_sts_region(config_file=config_file, profile_name="default") + + assert region == expected + + +@pytest.mark.parametrize( + "arn,expected", + [ + (ASSUMED_ROLE_ARN, "123456789012"), + ("arn:aws:sts:::assumed-role/MyRole/session", None), # empty account field + ("not-an-arn", None), # too few segments + (None, None), + ], +) +def test_account_id_from_arn(arn: str | None, expected: str | None) -> None: + assert _account_id_from_arn(arn) == expected + + +# --------------------------------------------------------------------------- +# AssumeRoleCredentialsResolver +# --------------------------------------------------------------------------- + + +async def test_resolves_identity_from_assume_role() -> None: + expiration = _future_expiry() + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), role_arn=ROLE_ARN + ) + _mock_sts_client(resolver, _valid_output(expiration=expiration)) + + identity = await resolver.get_identity(properties={}) + + assert identity.access_key_id == ACCESS_KEY_ID + assert identity.secret_access_key == SECRET_ACCESS_KEY + assert identity.session_token == SESSION_TOKEN + assert identity.expiration == expiration + assert identity.account_id == "123456789012" + + +async def test_missing_credentials_raises() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), role_arn=ROLE_ARN + ) + _mock_sts_client(resolver, AssumeRoleOutput(credentials=None)) + + with pytest.raises(SmithyIdentityError, match="did not contain credentials"): + await resolver.get_identity(properties={}) + + +async def test_valid_credentials_reused() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), role_arn=ROLE_ARN + ) + sts_client = _mock_sts_client( + resolver, + _valid_output(access_key_id="test-access-key-1"), + _valid_output(access_key_id="test-access-key-2"), + ) + + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + # The cached identity is returned without a second STS call. + assert identity_one is identity_two + assert sts_client.assume_role.call_count == 1 + + +async def test_expired_credentials_refreshed() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), role_arn=ROLE_ARN + ) + sts_client = _mock_sts_client( + resolver, + _valid_output(access_key_id="test-access-key-1", expiration=_past_expiry()), + _valid_output(access_key_id="test-access-key-2"), + ) + + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + # The cached identity is refreshed with a second STS call. + assert identity_one is not identity_two + assert identity_one.access_key_id == "test-access-key-1" + assert identity_two.access_key_id == "test-access-key-2" + assert sts_client.assume_role.call_count == 2 + + +async def test_assume_role_request_uses_role_arn() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), + role_arn=ROLE_ARN, + role_session_name="test-session-name", + ) + sts_client = _mock_sts_client(resolver, _valid_output()) + + await resolver.get_identity(properties={}) + + request = sts_client.assume_role.call_args.args[0] + assert request.role_arn == ROLE_ARN + assert request.role_session_name == "test-session-name" + + +async def test_assume_role_request_forwards_external_id() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), + role_arn=ROLE_ARN, + external_id="my-external-id", + ) + sts_client = _mock_sts_client(resolver, _valid_output()) + + await resolver.get_identity(properties={}) + + request = sts_client.assume_role.call_args.args[0] + assert request.external_id == "my-external-id" + + +async def test_role_session_name_generated_when_unset() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), role_arn=ROLE_ARN + ) + sts_client = _mock_sts_client(resolver, _valid_output()) + + await resolver.get_identity(properties={}) + + request = sts_client.assume_role.call_args.args[0] + assert request.role_session_name.startswith("aws-sdk-python-") + + +async def test_role_session_name_stable_across_refreshes() -> None: + resolver = AssumeRoleCredentialsResolver( + source_resolver=AsyncMock(), role_arn=ROLE_ARN + ) + sts_client = _mock_sts_client( + resolver, + _valid_output(expiration=_past_expiry()), + _valid_output(), + ) + + await resolver.get_identity(properties={}) + await resolver.get_identity(properties={}) + + first, second = sts_client.assume_role.call_args_list + assert first.args[0].role_session_name == second.args[0].role_session_name + + +async def test_invalidate_clears_cache_and_source() -> None: + source_resolver = AsyncMock() + resolver = AssumeRoleCredentialsResolver( + source_resolver=source_resolver, role_arn=ROLE_ARN + ) + sts_client = _mock_sts_client( + resolver, + _valid_output(access_key_id="test-access-key-1"), + _valid_output(access_key_id="test-access-key-2"), + ) + + identity_one = await resolver.get_identity(properties={}) + await resolver.invalidate() + identity_two = await resolver.get_identity(properties={}) + + assert identity_one.access_key_id == "test-access-key-1" + assert identity_two.access_key_id == "test-access-key-2" + assert sts_client.assume_role.call_count == 2 + source_resolver.invalidate.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# ProfileAssumeRoleCredentialsResolver +# --------------------------------------------------------------------------- + + +def test_missing_profile_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config({"default": {"region": "us-east-1"}}) + + with pytest.raises( + AssumeRoleConfigurationError, match="Profile 'missing' does not exist" + ): + ProfileAssumeRoleCredentialsResolver( + profile_name="missing", config_file=config_file + ) + + +async def test_source_profile_with_static_credentials( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": {"role_arn": ROLE_ARN, "source_profile": "base"}, + "base": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + }, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + delegate = await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + assert isinstance(delegate, AssumeRoleCredentialsResolver) + assert delegate._role_arn == ROLE_ARN + assert isinstance(delegate._source_resolver, StaticCredentialsResolver) + identity = await delegate._source_resolver.get_identity(properties={}) + assert identity.access_key_id == "akid" + assert identity.secret_access_key == "secret" + + +async def test_first_profile_credentials_ignored_in_favor_of_source( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": { + "role_arn": ROLE_ARN, + "source_profile": "base", + "aws_access_key_id": "ignored-akid", + "aws_secret_access_key": "ignored-secret", + }, + "base": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + }, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + delegate = await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + assert isinstance(delegate._source_resolver, StaticCredentialsResolver) + identity = await delegate._source_resolver.get_identity(properties={}) + assert identity.access_key_id == "akid" + assert identity.secret_access_key == "secret" + + +async def test_nested_source_profile_role_chain( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": {"role_arn": ROLE_ARN, "source_profile": "intermediate"}, + "intermediate": { + "role_arn": SOURCE_ROLE_ARN, + "source_profile": "base", + }, + "base": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + }, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + delegate = await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + # The outer role assumes via an inner AssumeRole resolver that itself + # sources from the static base profile. + assert isinstance(delegate, AssumeRoleCredentialsResolver) + inner = delegate._source_resolver + assert isinstance(inner, AssumeRoleCredentialsResolver) + assert inner._role_arn == SOURCE_ROLE_ARN + assert isinstance(inner._source_resolver, StaticCredentialsResolver) + + +async def test_chain_terminates_at_static_credentials( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": {"role_arn": ROLE_ARN, "source_profile": "middle"}, + "middle": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + "role_arn": SOURCE_ROLE_ARN, + "source_profile": "base", + }, + "base": { + "aws_access_key_id": "unused-akid", + "aws_secret_access_key": "unused-secret", + }, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + delegate = await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + assert delegate._role_arn == ROLE_ARN + assert isinstance(delegate._source_resolver, StaticCredentialsResolver) + identity = await delegate._source_resolver.get_identity(properties={}) + assert identity.access_key_id == "akid" + assert identity.secret_access_key == "secret" + + +async def test_missing_source_profile_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "source_profile": "ghost"}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, match="Source profile 'ghost' does not exist" + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_source_profile_without_credentials_or_role_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": {"role_arn": ROLE_ARN, "source_profile": "base"}, + "base": {"region": "us-east-1"}, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Source profile 'base' has no supported credential source", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_circular_source_profile_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "a": {"role_arn": ROLE_ARN, "source_profile": "b"}, + "b": {"role_arn": SOURCE_ROLE_ARN, "source_profile": "a"}, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="a", config_file=config_file + ) + + with pytest.raises(AssumeRoleConfigurationError, match="Circular"): + await resolver._create_assume_role_resolver(profile_name="a", visited=("a",)) + + +async def test_circular_source_profile_with_static_credentials_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "a": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + "role_arn": ROLE_ARN, + "source_profile": "b", + }, + "b": {"role_arn": SOURCE_ROLE_ARN, "source_profile": "a"}, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="a", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Circular source_profile reference: a -> b -> a", + ): + await resolver._create_assume_role_resolver(profile_name="a", visited=("a",)) + + +async def test_self_referencing_profile_requires_static_credentials( + merged_config: Callable[..., MergedConfig], +) -> None: + # A profile whose source_profile points at itself but has no static keys. + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "source_profile": "role"}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Self-referencing profile 'role' must contain complete static credentials", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_self_referencing_profile_with_static_credentials( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": { + "role_arn": ROLE_ARN, + "source_profile": "role", + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + } + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + delegate = await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + assert isinstance(delegate._source_resolver, StaticCredentialsResolver) + + +async def test_missing_role_arn_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config({"role": {"source_profile": "base"}}) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, match="Profile 'role' does not define role_arn" + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_both_source_and_credential_source_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": { + "role_arn": ROLE_ARN, + "source_profile": "base", + "credential_source": "Environment", + } + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Profile 'role' cannot define both 'source_profile' and 'credential_source'", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_neither_source_nor_credential_source_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config({"role": {"role_arn": ROLE_ARN}}) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Profile 'role' must define either 'source_profile' or 'credential_source'", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_partial_static_credentials_raise( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": {"role_arn": ROLE_ARN, "source_profile": "base"}, + "base": {"aws_access_key_id": "akid"}, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Profile 'base' contains partial credentials", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_unsupported_credential_source_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "credential_source": "ProfileSso"}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + + with pytest.raises( + AssumeRoleConfigurationError, + match="Unsupported 'credential_source': 'ProfileSso'", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +@pytest.mark.parametrize( + "credential_source", + ["Environment", "EcsContainer", "Ec2InstanceMetadata"], +) +async def test_credential_source_builds_resolver_from_provider( + merged_config: Callable[..., MergedConfig], + credential_source: str, +) -> None: + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "credential_source": credential_source}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + static = StaticCredentialsResolver( + AWSCredentialsIdentity(access_key_id="akid", secret_access_key="secret") + ) + resolver._find_provider = lambda slot: _FakeProvider(static) # type: ignore[assignment] + + delegate = await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + assert isinstance(delegate, AssumeRoleCredentialsResolver) + assert isinstance(delegate._source_resolver, NamedResolver) + assert delegate._source_resolver.resolver is static + + +async def test_credential_source_no_installed_provider_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "credential_source": "Ec2InstanceMetadata"}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + resolver._find_provider = lambda slot: None # type: ignore[assignment] + + with pytest.raises( + AssumeRoleConfigurationError, + match="No provider is installed for credential source 'Ec2InstanceMetadata'", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_credential_source_provider_registers_nothing_raises( + merged_config: Callable[..., MergedConfig], +) -> None: + class _EmptyProvider: + async def setup(self, identity_type: object, setup: ChainSetup) -> None: + return None + + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "credential_source": "Environment"}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + resolver._find_provider = lambda slot: _EmptyProvider() # type: ignore[assignment] + + with pytest.raises( + AssumeRoleConfigurationError, + match="'Environment' credential source is not configured", + ): + await resolver._create_assume_role_resolver( + profile_name="role", visited=("role",) + ) + + +async def test_get_identity_creates_and_reuses_delegate( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + { + "role": {"role_arn": ROLE_ARN, "source_profile": "base"}, + "base": { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + }, + } + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + expected = AWSCredentialsIdentity(access_key_id="a", secret_access_key="s") + delegate = AsyncMock() + delegate.get_identity.return_value = expected + + resolver._create_assume_role_resolver = AsyncMock() + resolver._create_assume_role_resolver.return_value = delegate + + first = await resolver.get_identity(properties={}) + second = await resolver.get_identity(properties={}) + + assert first is expected + assert second is expected + # The delegate is built once and reused across calls. + assert resolver._delegate is delegate + assert delegate.get_identity.await_count == 2 + + +async def test_invalidate_delegates_when_initialized( + merged_config: Callable[..., MergedConfig], +) -> None: + config_file = merged_config( + {"role": {"role_arn": ROLE_ARN, "source_profile": "base"}} + ) + resolver = ProfileAssumeRoleCredentialsResolver( + profile_name="role", config_file=config_file + ) + delegate = AsyncMock() + resolver._delegate = delegate + + await resolver.invalidate() + + delegate.invalidate.assert_awaited_once() From 812b4be2100681010cff121cbc515c24863802e3 Mon Sep 17 00:00:00 2001 From: Antonio Aranda <102337110+arandito@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:54:11 -0400 Subject: [PATCH 2/2] Update pyprojects with correct dependencies --- packages/aws-credentials-http/pyproject.toml | 5 ++--- packages/aws-credentials-imds/pyproject.toml | 5 ++--- packages/aws-credentials-sts/pyproject.toml | 7 +++---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/aws-credentials-http/pyproject.toml b/packages/aws-credentials-http/pyproject.toml index 4b50c8c..b0318f6 100644 --- a/packages/aws-credentials-http/pyproject.toml +++ b/packages/aws-credentials-http/pyproject.toml @@ -26,10 +26,9 @@ classifiers = [ "Programming Language :: Python :: Free Threading :: 2 - Beta", "Topic :: Software Development :: Libraries", ] -# TODO: Sync these with latest release before merging dependencies = [ - "smithy-aws-core~=0.7.0", - "smithy-core~=0.6.0", + "smithy-aws-core~=0.8.0", + "smithy-core~=0.7.0", "smithy-http[aiohttp]~=0.4.0", ] diff --git a/packages/aws-credentials-imds/pyproject.toml b/packages/aws-credentials-imds/pyproject.toml index 994d35d..cb96a59 100644 --- a/packages/aws-credentials-imds/pyproject.toml +++ b/packages/aws-credentials-imds/pyproject.toml @@ -26,10 +26,9 @@ classifiers = [ "Programming Language :: Python :: Free Threading :: 2 - Beta", "Topic :: Software Development :: Libraries", ] -# TODO: Sync these with latest release before merging dependencies = [ - "smithy-aws-core~=0.7.0", - "smithy-core~=0.6.0", + "smithy-aws-core~=0.8.0", + "smithy-core~=0.7.0", "smithy-http[aiohttp]~=0.4.0", ] diff --git a/packages/aws-credentials-sts/pyproject.toml b/packages/aws-credentials-sts/pyproject.toml index 0abb713..6af8ced 100644 --- a/packages/aws-credentials-sts/pyproject.toml +++ b/packages/aws-credentials-sts/pyproject.toml @@ -26,11 +26,10 @@ classifiers = [ "Programming Language :: Python :: Free Threading :: 2 - Beta", "Topic :: Software Development :: Libraries", ] -# TODO: Sync these with latest release before merging dependencies = [ - "aws-sdk-sts~=0.1.0", - "smithy-aws-core~=0.7.0", - "smithy-core~=0.6.0", + "aws-sdk-sts~=0.8.0", + "smithy-aws-core~=0.8.0", + "smithy-core~=0.7.0", "smithy-http~=0.4.0", ]