-
Notifications
You must be signed in to change notification settings - Fork 18
Add sts, imds, and http credential provider packages #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "type": "feature", | ||
| "description": "Add container HTTP credentials resolver and `EcsContainer` chain provider." | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| [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", | ||
| ] | ||
| dependencies = [ | ||
| "smithy-aws-core~=0.8.0", | ||
| "smithy-core~=0.7.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"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. botocore allows any host over HTTPS and only restricts plain HTTP to loopback/allowlisted hosts: def _validate_allowed_url(self, full_url):
parsed = botocore.compat.urlparse(full_url)
if parsed.scheme == 'https':
return
if self._is_loopback_address(parsed.hostname):
return
is_whitelisted_host = self._check_if_whitelisted_host(parsed.hostname)
if not is_whitelisted_host:
raise ValueError(
f"Unsupported host '{parsed.hostname}'. Can only retrieve metadata "
f"from a loopback address or one of these hosts: {', '.join(self._ALLOWED_HOSTS)}"
)Are we missing the HTTPS check here, or is it intentional? |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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])) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The token goes into the def _build_headers(self):
auth_token = None
if self.ENV_VAR_AUTH_TOKEN_FILE in self._environ:
auth_token_file_path = self._environ[self.ENV_VAR_AUTH_TOKEN_FILE]
with open(auth_token_file_path) as token_file:
auth_token = token_file.read()
elif self.ENV_VAR_AUTH_TOKEN in self._environ:
auth_token = self._environ[self.ENV_VAR_AUTH_TOKEN]
if auth_token is not None:
self._validate_auth_token(auth_token)
return {'Authorization': auth_token}
def _validate_auth_token(self, auth_token):
if "\r" in auth_token or "\n" in auth_token:
raise ValueError("Auth token value is not a legal header value")Should we add some checks as well? |
||
|
|
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self._timeoutseems never used. Should it be applied to the request, or should the parameter be dropped? Or do you want to to add a TODO?