Skip to content

Add sts, imds, and http credential provider packages - #72

Open
arandito wants to merge 2 commits into
aws:developfrom
arandito:network-credential-providers
Open

Add sts, imds, and http credential provider packages#72
arandito wants to merge 2 commits into
aws:developfrom
arandito:network-credential-providers

Conversation

@arandito

@arandito arandito commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Description

This PR introduces three new packages that register network credential providers into the SDK's modular AWS credential chain:

  • aws-credentials-imds - EC2 Instance Metadata Service (IMDSv2) credential resolver and Ec2InstanceMetadata chain provider
  • aws-credentials-http - container HTTP credential resolver (ECS/EKS) and EcsContainer chain provider
  • aws-credentials-sts - STS AssumeRole resolvers and ProfileAssumeRole chain provider

IMDS and HTTP are direct ports of the existing smithy_aws_core.identity.imds and container resolvers, which will be deprecated. The credential resolution behavior is mostly identical. Changes are limited to:

  • Module reorganization into standalone packages (client.py/resolvers.py/providers.py split).
  • Naming cleanup (e.g. ContainerMetadataClient becomes HttpCredentialsClient, EC2Metadata becomes IMDSClient, etc).
  • ContainerCredentialsConfig is flattened into class constructor arguments to make user interface cleaner.
  • New provider/chain wiring that sources configuration from the environment and shared config profile. The original resolvers had no chain integration.
  • Config-validation errors now raise package-specific *ConfigurationError(SmithyError) types instead of bare ValueError, reserving SmithyIdentityError for resolution-time failures.

STS is new. It ships two resolvers that separate the AssumeRole call itself from the profile configuration that feeds it:

  • AssumeRoleCredentialsResolver performs the STS AssumeRole call. It takes an explicit role_arn and a source_resolver that provides the credentials used to make the call. This is the low-level resolver, with no knowledge of profiles, and can be used standalone outside the chain.
  • ProfileAssumeRoleCredentialsResolver is the profile-driven resolver used by the chain. It reads a profile from the shared config file and resolves the credential source from that profile's source_profile (chaining to another profile, including nested role chains that terminate in static credentials) or credential_source (delegating to the Environment, EcsContainer, or Ec2InstanceMetadata provider). It then hands that source to an AssumeRoleCredentialsResolver to perform the call.

Splitting the two keeps the STS call logic isolated and reusable. AssumeRoleCredentialsResolver can be constructed directly with any source resolver, while ProfileAssumeRoleCredentialsResolver owns only the profile parsing and source resolution.

Important

The underlying STS client used for Assume Role calls in imported from the aws-sdk-sts client. This means that aws-credentials-sts has a required dependency on aws-sdk-sts. This does not cause a dependency cycle as the aws-sdk-sts client will never have a required dependency on aws-credentials-sts and instead is opt in. If artifact size for aws-credentials-sts becomes a concern due to the full import of the STS client package, we can explore a slim client implementation. For now, this is the most maintainable solution.

Usage

Installing any of these packages auto-registers its provider into the SDK's credential chain via entry points. Each resolver can also be used directly:

from aws_credentials_imds import IMDSCredentialsResolver
from aws_credentials_http import ContainerCredentialsResolver
from aws_credentials_sts import AssumeRoleCredentialsResolver

# IMDS / container: HTTP client defaults to AIOHTTPClient
resolver = IMDSCredentialsResolver()
credentials = await resolver.get_identity(properties={})

# STS AssumeRole with any source resolver
resolver = AssumeRoleCredentialsResolver(
    source_resolver=source_resolver,
    role_arn="arn:aws:iam::123456789012:role/example",
)
credentials = await resolver.get_identity(properties={})

Testing

  • Ported existing test suites for IMDS and Container, with slight modification for new interfaces.
  • Added unit test coverage for new STS credential resolvers and for the three new chain providers.
  • Tested all three packages standalone and integrated in the new IdentityChain with live service calls
    • Configuration:
      • STS: Set up iam roles to assume on AWS Console
      • IMDS: Set up EC2 instance with an IMDS instance profile + role.
      • HTTP: Used amazon-ecs-local-container-endpoints to host container endpoints locally.
    • Confirmed all providers are configured automatically with ~/.aws/config profiles and environment variables.
    • Confirmed chain order is preserved when multiple sources are configured.
    • Confirmed IMDS and HTTP work as credential sources for STS providers

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@arandito
arandito requested a review from a team as a code owner July 31, 2026 15:02
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?

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?

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants