Skip to content

Commit 3d8ae36

Browse files
committed
Add ContainerCredentialResovler
1 parent ee10a01 commit 3d8ae36

File tree

2 files changed

+468
-0
lines changed

2 files changed

+468
-0
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import asyncio
2+
import ipaddress
3+
import json
4+
import os
5+
from dataclasses import dataclass
6+
from datetime import UTC, datetime
7+
from urllib.parse import urlparse
8+
9+
from smithy_core import URI
10+
from smithy_core.aio.interfaces.identity import IdentityResolver
11+
from smithy_core.exceptions import SmithyIdentityError
12+
from smithy_http import Field, Fields
13+
from smithy_http.aio import HTTPRequest
14+
from smithy_http.aio.interfaces import HTTPClient, HTTPResponse
15+
16+
from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties
17+
18+
_CONTAINER_METADATA_IP = "169.254.170.2"
19+
_CONTAINER_METADATA_ALLOWED_HOSTS = {
20+
_CONTAINER_METADATA_IP,
21+
"169.254.170.23",
22+
"fd00:ec2::23",
23+
"localhost",
24+
}
25+
_DEFAULT_TIMEOUT = 2
26+
_DEFAULT_RETRIES = 3
27+
_SLEEP_SECONDS = 1
28+
29+
30+
@dataclass
31+
class ContainerCredentialConfig:
32+
"""Configuration for container credential retrieval operations."""
33+
34+
timeout: int = _DEFAULT_TIMEOUT
35+
retries: int = _DEFAULT_RETRIES
36+
37+
38+
class ContainerMetadataClient:
39+
"""Client for remote credential retrieval in Container environments like ECS/EKS."""
40+
41+
def __init__(self, http_client: HTTPClient, config: ContainerCredentialConfig):
42+
self._http_client = http_client
43+
self._config = config
44+
45+
def _validate_allowed_url(self, uri: URI) -> None:
46+
if self._is_loopback(uri.host):
47+
return
48+
49+
if not self._is_allowed_container_metadata_host(uri.host):
50+
raise SmithyIdentityError(
51+
f"Unsupported host '{uri.host}'. "
52+
f"Can only retrieve metadata from a loopback address or "
53+
f"one of: {', '.join(_CONTAINER_METADATA_ALLOWED_HOSTS)}"
54+
)
55+
56+
async def get_credentials(self, uri: URI, fields: Fields) -> dict[str, str]:
57+
self._validate_allowed_url(uri)
58+
fields.set_field(Field(name="Accept", values=["application/json"]))
59+
60+
attempts = 0
61+
last_exc = None
62+
while attempts < self._config.retries:
63+
try:
64+
request = HTTPRequest(
65+
method="GET",
66+
destination=uri,
67+
fields=fields,
68+
)
69+
response: HTTPResponse = await self._http_client.send(request)
70+
body = await response.consume_body_async()
71+
if response.status != 200:
72+
raise SmithyIdentityError(
73+
f"Container metadata service returned {response.status}: "
74+
f"{body.decode('utf-8')}"
75+
)
76+
try:
77+
return json.loads(body.decode("utf-8"))
78+
except Exception as e:
79+
raise SmithyIdentityError(
80+
f"Unable to parse JSON from container metadata: {body.decode('utf-8')}"
81+
) from e
82+
except Exception as e:
83+
last_exc = e
84+
await asyncio.sleep(_SLEEP_SECONDS)
85+
attempts += 1
86+
87+
raise SmithyIdentityError(
88+
f"Failed to retrieve container metadata after {self._config.retries} attempt(s)"
89+
) from last_exc
90+
91+
def _is_loopback(self, hostname: str) -> bool:
92+
try:
93+
return ipaddress.ip_address(hostname).is_loopback
94+
except ValueError:
95+
return False
96+
97+
def _is_allowed_container_metadata_host(self, hostname: str) -> bool:
98+
return hostname in _CONTAINER_METADATA_ALLOWED_HOSTS
99+
100+
101+
class ContainerCredentialResolver(
102+
IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties]
103+
):
104+
"""Resolves AWS Credentials from container credential sources."""
105+
106+
ENV_VAR = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
107+
ENV_VAR_FULL = "AWS_CONTAINER_CREDENTIALS_FULL_URI"
108+
ENV_VAR_AUTH_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN" # noqa: S105
109+
ENV_VAR_AUTH_TOKEN_FILE = "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE" # noqa: S105
110+
111+
def __init__(
112+
self,
113+
http_client: HTTPClient,
114+
config: ContainerCredentialConfig | None = None,
115+
):
116+
self._http_client = http_client
117+
self._config = config or ContainerCredentialConfig()
118+
# These must be awaited, so moved to get_identity
119+
self._client = ContainerMetadataClient(http_client, self._config)
120+
self._credentials = None
121+
122+
async def _resolve_uri_from_env(self) -> URI:
123+
if self.ENV_VAR in os.environ:
124+
return URI(
125+
scheme="http",
126+
host=_CONTAINER_METADATA_IP,
127+
path=os.environ[self.ENV_VAR],
128+
)
129+
elif self.ENV_VAR_FULL in os.environ:
130+
parsed = urlparse(os.environ[self.ENV_VAR_FULL])
131+
return URI(
132+
scheme=parsed.scheme,
133+
host=parsed.hostname or "",
134+
port=parsed.port,
135+
path=parsed.path,
136+
)
137+
else:
138+
raise SmithyIdentityError(
139+
f"Neither {self.ENV_VAR} or {self.ENV_VAR_FULL} environment "
140+
"variables are set. Unable to resolve credentials."
141+
)
142+
143+
async def _resolve_fields_from_env(self) -> Fields:
144+
fields = Fields()
145+
if self.ENV_VAR_AUTH_TOKEN_FILE in os.environ:
146+
try:
147+
filename = os.environ[self.ENV_VAR_AUTH_TOKEN_FILE]
148+
auth_token = await asyncio.to_thread(self._read_file, filename)
149+
except (FileNotFoundError, PermissionError) as e:
150+
raise SmithyIdentityError(
151+
f"Unable to open {os.environ[self.ENV_VAR_AUTH_TOKEN_FILE]}."
152+
) from e
153+
154+
fields.set_field(Field(name="Authorization", values=[auth_token]))
155+
elif self.ENV_VAR_AUTH_TOKEN in os.environ:
156+
auth_token = os.environ[self.ENV_VAR_AUTH_TOKEN]
157+
fields.set_field(Field(name="Authorization", values=[auth_token]))
158+
159+
return fields
160+
161+
def _read_file(self, filename: str) -> str:
162+
with open(filename) as f:
163+
return f.read().strip()
164+
165+
async def get_identity(
166+
self, *, properties: AWSIdentityProperties
167+
) -> AWSCredentialsIdentity:
168+
uri = await self._resolve_uri_from_env()
169+
fields = await self._resolve_fields_from_env()
170+
creds = await self._client.get_credentials(uri, fields)
171+
172+
access_key_id = creds.get("AccessKeyId")
173+
secret_access_key = creds.get("SecretAccessKey")
174+
session_token = creds.get("Token")
175+
expiration = creds.get("Expiration")
176+
account_id = creds.get("AccountId", None)
177+
178+
if isinstance(expiration, str):
179+
expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC)
180+
181+
if access_key_id is None or secret_access_key is None:
182+
raise SmithyIdentityError(
183+
"AccessKeyId and SecretAccessKey are required for container credentials"
184+
)
185+
186+
self._credentials = AWSCredentialsIdentity(
187+
access_key_id=access_key_id,
188+
secret_access_key=secret_access_key,
189+
session_token=session_token,
190+
expiration=expiration,
191+
account_id=account_id,
192+
)
193+
return self._credentials

0 commit comments

Comments
 (0)