Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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."
}
1 change: 1 addition & 0 deletions packages/aws-credentials-http/NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5 changes: 5 additions & 0 deletions packages/aws-credentials-http/README.md
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.
55 changes: 55 additions & 0 deletions packages/aws-credentials-http/pyproject.toml
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self._timeout seems never used. Should it be applied to the request, or should the parameter be dropped? Or do you want to to add a TODO?

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 @@

134 changes: 134 additions & 0 deletions packages/aws-credentials-http/src/aws_credentials_http/resolvers.py
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]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token goes into the Authorization header unvalidated, while botocore rejects \r and \n:

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
Loading