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": "Added process credentials support to the default AWS identity chain through the active profile's `credential_process` setting."
}
1 change: 1 addition & 0 deletions packages/smithy-aws-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Environment = "smithy_aws_core.identity.chain.providers.environment:EnvironmentC
SharedConfig = "smithy_aws_core.identity.chain.providers.shared_config:SharedConfigProvider"
ProfileSessionKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileSessionCredentialsProvider"
ProfileStaticKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileStaticCredentialsProvider"
ProfileCredentialProcess = "smithy_aws_core.identity.chain.providers.process:ProfileProcessCredentialsProvider"

[build-system]
requires = ["hatchling"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from .chain import IdentityChain, IdentityChainError, UnclaimedSource
from .chain.providers.environment import EnvironmentCredentialsProvider
from .chain.providers.process import ProfileProcessCredentialsProvider
from .chain.providers.profile import (
ProfileSessionCredentialsProvider,
ProfileStaticCredentialsProvider,
Expand All @@ -18,6 +19,7 @@
from .container import ContainerCredentialsResolver
from .environment import EnvironmentCredentialsResolver
from .imds import IMDSCredentialsResolver
from .process import ProcessCredentialsResolver
from .static import StaticCredentialsResolver

__all__ = (
Expand All @@ -30,6 +32,8 @@
"IMDSCredentialsResolver",
"IdentityChain",
"IdentityChainError",
"ProcessCredentialsResolver",
"ProfileProcessCredentialsProvider",
"ProfileSessionCredentialsProvider",
"ProfileStaticCredentialsProvider",
"SharedConfigProvider",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import shlex
import sys

from smithy_core.interfaces.identity import Identity

from ...components import AWSCredentialsIdentity
from ...process import ProcessCredentialsResolver
from ..ordering import Standard, StandardProvider
from ..provider import ChainSetup

_CREDENTIAL_PROCESS = "credential_process"
_ACCOUNT_ID = "aws_account_id"


def _split_process_command(
command: str,
*,
platform: str | None = None,
) -> list[str]:
"""Split a process command according to the host platform's quoting rules."""
if platform is None:
platform = sys.platform
if platform == "win32":
return _split_windows_command(command)
return shlex.split(command)


def _split_windows_command(command: str) -> list[str]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note for reviewer: This is inspired by botocore's _windows_shell_split utility function.

"""Split a command using botocore's strict form of the Microsoft C runtime rules.

The underlying runtime rules are documented at:
https://learn.microsoft.com/en-us/cpp/cpp/main-function-command-line-args#parsing-c-command-line-arguments
"""
arguments: list[str] = []
argument: list[str] = []
argument_started = False
in_quotes = False
backslashes = 0

for character in command:
if character == "\\":
# Delay emitting backslashes until we know whether a quote follows.
backslashes += 1
argument_started = True
continue

if character == '"':
# Pairs become literal backslashes; an odd remainder escapes the quote.
literal_backslashes, escaped_quote = divmod(backslashes, 2)
argument.extend("\\" * literal_backslashes)
backslashes = 0
argument_started = True
if escaped_quote:
argument.append('"')
else:
in_quotes = not in_quotes
continue

if backslashes:
# Without a following quote, backslashes are literal.
argument.extend("\\" * backslashes)
backslashes = 0

# Only spaces and tabs outside quotes delimit Windows arguments.
if character in (" ", "\t") and not in_quotes:
# This preserves empty quoted arguments while ignoring extra whitespace.
if argument_started:
arguments.append("".join(argument))
argument = []
argument_started = False
continue

argument.append(character)
argument_started = True

if in_quotes:
raise ValueError(f"No closing quotation in string: {command}")

if backslashes:
argument.extend("\\" * backslashes)
if argument_started:
arguments.append("".join(argument))

return arguments


class ProfileProcessCredentialsProvider:
"""Adds a process credential resolver configured by the active profile."""

@property
def name(self) -> str:
"""Return the canonical provider name."""
return StandardProvider.PROFILE_CREDENTIAL_PROCESS.canonical_name

@property
def ordering(self) -> Standard:
"""Return the provider's standard chain position."""
return Standard(slot=StandardProvider.PROFILE_CREDENTIAL_PROCESS)

async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None:
"""Add a resolver when the active profile configures a credential process."""
if identity_type is not AWSCredentialsIdentity:
return

config_file = setup.config_file
profile_name = setup.profile_name
if config_file is None or profile_name is None:
return

command = config_file.get(profile_name, _CREDENTIAL_PROCESS)
if not command:
return

# The process output's AccountId takes precedence; the profile's
# aws_account_id is only used as a fallback.
setup.add_terminal_resolver(
ProcessCredentialsResolver(
_split_process_command(command),
account_id=config_file.get(profile_name, _ACCOUNT_ID),
)
)
141 changes: 141 additions & 0 deletions packages/smithy-aws-core/src/smithy_aws_core/identity/process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import asyncio
Comment thread
jonathan343 marked this conversation as resolved.
import json
from datetime import UTC, datetime
from typing import TypeGuard, cast

from smithy_core.aio.interfaces.identity import IdentityResolver
from smithy_core.exceptions import SmithyIdentityError

from .components import AWSCredentialsIdentity, AWSIdentityProperties


def _is_command_list(command: object) -> TypeGuard[list[str]]:
if not isinstance(command, list) or not command:
return False
return all(isinstance(argument, str) for argument in cast(list[object], command))


class ProcessCredentialsResolver(
IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties]
):
"""Resolves AWS Credentials from a process.

:param command: The process command and arguments to execute, as a
non-empty list of strings.
:param timeout: Maximum time in seconds to wait for the process to complete.
:param account_id: Fallback account ID to associate with the resolved
credentials when the process output does not include an ``AccountId``.
"""

def __init__(
self,
command: list[str],
Comment thread
jonathan343 marked this conversation as resolved.
*,
timeout: float | None = None,
account_id: str | None = None,
) -> None:
if not _is_command_list(command):
raise ValueError("command must be a non-empty list of strings")
self._command = list(command)
self._timeout = timeout
self._account_id = account_id
self._credentials: AWSCredentialsIdentity | None = None

async def get_identity(
self, *, properties: AWSIdentityProperties
) -> AWSCredentialsIdentity:
if self._credentials is not None:
# Long-term credentials (no expiration) should always be reused
if self._credentials.expiration is None:
return self._credentials
# Temporary credentials should be reused if not expired
if datetime.now(UTC) < self._credentials.expiration:
Comment thread
jonathan343 marked this conversation as resolved.
return self._credentials

try:
process = await asyncio.create_subprocess_exec(
*self._command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
raise SmithyIdentityError(f"Credential process failed to start: {e}") from e

try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=self._timeout
)
except TimeoutError as e:
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
await process.wait()
raise SmithyIdentityError(
f"Credential process timed out after {self._timeout} seconds"
) from e

if process.returncode != 0:
raise SmithyIdentityError(
f"Credential process failed with exit code {process.returncode}: "
f"{stderr.decode('utf-8', errors='replace')}"
)
# These exceptions retain the full process output, which may contain
# credentials. Suppress chaining to avoid exposing it in tracebacks.
try:
decoded = stdout.decode("utf-8")
creds = json.loads(decoded)
except UnicodeDecodeError as e:
raise SmithyIdentityError(
"Credential process output is not valid UTF-8 "
f"at byte {e.start}: {e.reason}"
) from None
except json.JSONDecodeError as e:
raise SmithyIdentityError(
"Credential process output is not valid JSON "
f"at line {e.lineno}, column {e.colno}: {e.msg}"
) from None

version = creds.get("Version")
if version != 1:
raise SmithyIdentityError(
f"Unsupported version '{version}' for credential process provider, supported versions: 1"
)
access_key_id = creds.get("AccessKeyId")
secret_access_key = creds.get("SecretAccessKey")
session_token = creds.get("SessionToken")
expiration = creds.get("Expiration")
# Prefer the process output's AccountId, falling back to the profile's
# aws_account_id when the process omits it.
account_id = creds.get("AccountId") or self._account_id

if expiration is not None:
try:
dt = datetime.fromisoformat(expiration)
except (TypeError, ValueError) as e:
raise SmithyIdentityError(
"Invalid credential process Expiration; "
f"expected an ISO 8601 string: {e}"
) from e
expiration = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC)

if access_key_id is None or secret_access_key is None:
raise SmithyIdentityError(
"AccessKeyId and SecretAccessKey are required for process 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 reruns the process."""
self._credentials = None
Loading
Loading