Skip to content

Commit 7615abe

Browse files
authored
Add sts, imds, and http credential provider packages (#72)
1 parent e32f468 commit 7615abe

36 files changed

Lines changed: 3563 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "feature",
3+
"description": "Add container HTTP credentials resolver and `EcsContainer` chain provider."
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# aws-credentials-http
2+
3+
This package provides a container HTTP credential resolver and chain provider.
4+
5+
## Installation
6+
7+
```shell
8+
uv pip install aws-credentials-http
9+
```
10+
11+
Once installed, the provider registers itself with the SDK's modular credential
12+
chain. When a client resolves credentials through the default chain, it
13+
will attempt this source when the container credential environment variables
14+
(`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` or `AWS_CONTAINER_CREDENTIALS_FULL_URI`)
15+
are set, unless a higher-precedence source resolves credentials first.
16+
17+
## Client Configuration
18+
19+
To use this resolver explicitly, set the `aws_credentials_identity_resolver`
20+
property on a service client's config to a `ContainerCredentialsResolver`
21+
instance:
22+
23+
```python
24+
from aws_credentials_http import ContainerCredentialsResolver
25+
26+
service_client = ServiceClient(
27+
config=ServiceClientConfig(
28+
aws_credentials_identity_resolver=ContainerCredentialsResolver(),
29+
)
30+
)
31+
```
32+
33+
## Standalone
34+
35+
The resolver can also be used on its own to fetch credentials directly:
36+
37+
```python
38+
import asyncio
39+
40+
from aws_credentials_http import ContainerCredentialsResolver
41+
42+
async def main() -> None:
43+
resolver = ContainerCredentialsResolver()
44+
identity = await resolver.get_identity(properties={})
45+
46+
asyncio.run(main())
47+
```
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
[project]
2+
name = "aws-credentials-http"
3+
dynamic = ["version"]
4+
requires-python = ">=3.12"
5+
authors = [
6+
{name = "Amazon Web Services"},
7+
]
8+
description = "HTTP endpoint credentials support for the AWS SDK for Python."
9+
readme = "README.md"
10+
license = {text = "Apache License 2.0"}
11+
keywords = ["aws", "credentials", "http", "ecs", "eks", "sdk", "smithy"]
12+
classifiers = [
13+
"Development Status :: 2 - Pre-Alpha",
14+
"Intended Audience :: Developers",
15+
"Intended Audience :: System Administrators",
16+
"Natural Language :: English",
17+
"License :: OSI Approved :: Apache Software License",
18+
"Operating System :: OS Independent",
19+
"Programming Language :: Python",
20+
"Programming Language :: Python :: 3 :: Only",
21+
"Programming Language :: Python :: 3",
22+
"Programming Language :: Python :: 3.12",
23+
"Programming Language :: Python :: 3.13",
24+
"Programming Language :: Python :: 3.14",
25+
"Programming Language :: Python :: Implementation :: CPython",
26+
"Programming Language :: Python :: Free Threading :: 2 - Beta",
27+
"Topic :: Software Development :: Libraries",
28+
]
29+
dependencies = [
30+
"smithy-aws-core~=0.8.0",
31+
"smithy-core~=0.7.0",
32+
"smithy-http[aiohttp]~=0.4.0",
33+
]
34+
35+
[project.urls]
36+
"Code" = "https://github.com/aws/aws-sdk-python/tree/develop/packages/aws-credentials-http/"
37+
"Issue tracker" = "https://github.com/aws/aws-sdk-python/issues"
38+
39+
[project.entry-points."smithy_aws_core.identity.chain_providers"]
40+
EcsContainer = "aws_credentials_http.providers:EcsContainerProvider"
41+
42+
[build-system]
43+
requires = ["hatchling"]
44+
build-backend = "hatchling.build"
45+
46+
[tool.hatch.version]
47+
path = "src/aws_credentials_http/__init__.py"
48+
49+
[tool.hatch.build]
50+
exclude = [
51+
"tests",
52+
]
53+
54+
[tool.ruff]
55+
src = ["src"]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
__version__ = "0.0.0"
4+
5+
from .providers import EcsContainerProvider
6+
from .resolvers import ContainerCredentialsResolver
7+
8+
__all__ = (
9+
"ContainerCredentialsResolver",
10+
"EcsContainerProvider",
11+
)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import asyncio
4+
import ipaddress
5+
import json
6+
7+
from smithy_core import URI
8+
from smithy_core.exceptions import SmithyIdentityError
9+
from smithy_http import Field, Fields
10+
from smithy_http.aio import HTTPRequest
11+
from smithy_http.aio.interfaces import HTTPClient, HTTPResponse
12+
from smithy_http.interfaces import HTTPRequestConfiguration
13+
14+
_CONTAINER_METADATA_IP = "169.254.170.2"
15+
_CONTAINER_METADATA_ALLOWED_HOSTS = {
16+
_CONTAINER_METADATA_IP,
17+
"169.254.170.23",
18+
"fd00:ec2::23",
19+
"localhost",
20+
}
21+
_DEFAULT_TIMEOUT = 2
22+
_DEFAULT_RETRIES = 3
23+
_SLEEP_SECONDS = 1
24+
25+
26+
class HttpCredentialsClient:
27+
"""Retrieves AWS credentials from an HTTP credentials endpoint."""
28+
29+
def __init__(
30+
self,
31+
http_client: HTTPClient,
32+
*,
33+
timeout: int = _DEFAULT_TIMEOUT,
34+
retries: int = _DEFAULT_RETRIES,
35+
):
36+
self._http_client = http_client
37+
# TODO: Also apply this value as the connect timeout once smithy_http's
38+
# HTTPRequestConfiguration supports it.
39+
self._timeout = timeout
40+
self._retries = retries
41+
42+
async def get_credentials(self, uri: URI, fields: Fields) -> dict[str, str]:
43+
self._validate_allowed_url(uri)
44+
fields.set_field(Field(name="Accept", values=["application/json"]))
45+
46+
attempts = 0
47+
last_exc = None
48+
while attempts < self._retries:
49+
try:
50+
request = HTTPRequest(
51+
method="GET",
52+
destination=uri,
53+
fields=fields,
54+
)
55+
response: HTTPResponse = await self._http_client.send(
56+
request,
57+
request_config=HTTPRequestConfiguration(read_timeout=self._timeout),
58+
)
59+
body = await response.consume_body_async()
60+
if response.status != 200:
61+
raise SmithyIdentityError(
62+
f"Container metadata service returned {response.status}: "
63+
f"{body.decode('utf-8')}"
64+
)
65+
try:
66+
return json.loads(body.decode("utf-8"))
67+
except Exception as error:
68+
raise SmithyIdentityError(
69+
"Unable to parse JSON from container metadata: "
70+
f"{body.decode('utf-8')}"
71+
) from error
72+
except Exception as error:
73+
last_exc = error
74+
await asyncio.sleep(_SLEEP_SECONDS)
75+
attempts += 1
76+
77+
raise SmithyIdentityError(
78+
f"Failed to retrieve container metadata after {self._retries} attempt(s)"
79+
) from last_exc
80+
81+
def _validate_allowed_url(self, uri: URI) -> None:
82+
if uri.scheme == "https":
83+
return
84+
85+
if self._is_loopback(uri.host):
86+
return
87+
88+
if not self._is_allowed_container_metadata_host(uri.host):
89+
raise SmithyIdentityError(
90+
f"Unsupported host '{uri.host}'. "
91+
f"Can only retrieve metadata from an HTTPS endpoint, a loopback "
92+
f"address, or one of: {', '.join(_CONTAINER_METADATA_ALLOWED_HOSTS)}"
93+
)
94+
95+
def _is_loopback(self, hostname: str) -> bool:
96+
try:
97+
return ipaddress.ip_address(hostname).is_loopback
98+
except ValueError:
99+
return False
100+
101+
def _is_allowed_container_metadata_host(self, hostname: str) -> bool:
102+
return hostname in _CONTAINER_METADATA_ALLOWED_HOSTS
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import os
4+
5+
from smithy_aws_core.identity import AWSCredentialsIdentity
6+
from smithy_aws_core.identity.chain import Standard, StandardProvider
7+
from smithy_aws_core.identity.chain.provider import ChainSetup
8+
from smithy_core.interfaces.identity import Identity
9+
10+
from .resolvers import ContainerCredentialsResolver
11+
12+
_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
13+
_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI"
14+
15+
16+
class EcsContainerProvider:
17+
"""Adds a container credential resolver to the credential chain."""
18+
19+
@property
20+
def name(self) -> str:
21+
"""Return the canonical provider name."""
22+
return StandardProvider.ECS_CONTAINER.canonical_name
23+
24+
@property
25+
def ordering(self) -> Standard:
26+
"""Return the provider's standard chain position."""
27+
return Standard(slot=StandardProvider.ECS_CONTAINER)
28+
29+
async def setup(
30+
self,
31+
identity_type: type[Identity],
32+
setup: ChainSetup,
33+
) -> None:
34+
"""Add a terminal resolver when a container endpoint is configured."""
35+
if identity_type is not AWSCredentialsIdentity:
36+
return
37+
if not os.getenv(_RELATIVE_URI) and not os.getenv(_FULL_URI):
38+
return
39+
setup.add_terminal_resolver(
40+
ContainerCredentialsResolver(http_client=setup.http_client)
41+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)