Skip to content

Add AWS Process Credential Resolver#658

Open
jonathan343 wants to merge 6 commits intodevelopfrom
process-cred-resolver
Open

Add AWS Process Credential Resolver#658
jonathan343 wants to merge 6 commits intodevelopfrom
process-cred-resolver

Conversation

@jonathan343
Copy link
Contributor

@jonathan343 jonathan343 commented Mar 15, 2026

Important

This PR adds a standalone AWS process credential resolver. We do not currently support reading from the shared AWS config file. However, this will unblock anyone from using a process based credential provider.

Overview

This PR adds an AWS process-based credentials resolver to smithy-aws-core so credential providers can source credentials from an external command, while handling the common failure modes and caching behavior expected for both long-lived and temporary credentials.

Summary

  • Introduces ProcessCredentialsResolver and ProcessCredentialsConfig in the AWS identity package and exports them as part of the public identity surface.
  • Executes a configured credential process asynchronously, validates the returned JSON payload, and converts it into an AWSCredentialsIdentity.
  • Handles operational edge cases around process startup failures, timeouts, non-zero exits, unsupported response versions, and missing required credential fields.
  • Reuses cached credentials when they are still valid and refreshes them when temporary credentials have expired.
  • Improves command handling so callers can provide either explicit argument lists or a shell-style command string.

Testing

  • Adds unit coverage for successful credential loading, optional fields, validation failures, timeout and startup error handling, chained-resolver fallback behavior, credential caching/refresh, and multi-argument command parsing.

Note

This was tested with internal AWS process tools for sourcing credentials. TODO: Test with popular external tools.

import asyncio

from aws_sdk_bedrock_runtime.client import BedrockRuntimeClient
from aws_sdk_bedrock_runtime.config import Config
from aws_sdk_bedrock_runtime.models import (
    ContentBlockText,
    ConverseInput,
    Message,
)
from smithy_aws_core.identity import ProcessCredentialsResolver


async def main():
    client = BedrockRuntimeClient(
        config=Config(
            region="us-west-2",
            aws_credentials_identity_resolver=ProcessCredentialsResolver(
                command=["<insert command here>"]
            ),
        )
    )
    input_message = Message(
        role="user", content=[ContentBlockText(value="What is boto3?")]
    )
    response = await client.converse(
        ConverseInput(
            model_id="global.amazon.nova-2-lite-v1:0", messages=[input_message]
        )
    )

    output_message = response.output
    print(output_message)


asyncio.run(main())

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

@jonathan343 jonathan343 force-pushed the process-cred-resolver branch from 0e39eb8 to 246f957 Compare March 16, 2026 15:15
@jonathan343 jonathan343 marked this pull request as ready for review March 16, 2026 16:51
@jonathan343 jonathan343 requested a review from a team as a code owner March 16, 2026 16:51

def __init__(
self,
command: list[str],
Copy link
Contributor

Choose a reason for hiding this comment

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

The PR description says we will accept a command string:

Improves command handling so callers can provide either explicit argument lists or a shell-style command string.

Looking at the commit history, it looks like you intentionally removed this capability. I'm alright with this; we can always add support later. What was the reason for the revert?


if process.returncode != 0:
raise SmithyIdentityError(
f"Credential process failed with non-zero exit code: {stderr.decode('utf-8')}"
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we include the returncode in the exception message in case it is useful in debugging?

Copy link
Contributor

Choose a reason for hiding this comment

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

Second comment here - we are assuming that this will always be parse-able as valid UTF-8.

While any other response encoding would be considered malformed output, a user would see a UnicodeDecodeError instead of the SmithyIdentityError. We should be guarding against other encodings or binary garbage being returned by the unknown process.

@@ -0,0 +1,115 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import asyncio
Copy link
Contributor

Choose a reason for hiding this comment

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

High level: I'm starting to get a little concerned with all of the things we are missing since we don't have support for them yet and how we will track them. In this case, it's feature id tracking and adding this to the default chain (which will need to look in the config file).

Should we add some TODOs here?

creds = json.loads(stdout.decode("utf-8"))
except json.JSONDecodeError as e:
raise SmithyIdentityError(
f"Failed to parse credential process output: {e}"
Copy link
Contributor

Choose a reason for hiding this comment

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

[Non-blocking]

JSONDecodeError contains the json document being parsed. The string representation of this generally does not contain the JSON document, but any downstream error parser may wind up leaking any secrets that may have been contained in this json error.

The alternative here is to not raise this error using from e, but just raise it alone.

Since this scenario seems far-fetched, and SDK users should not be sharing raw error info with untrusted users, I'd say this approach is sufficiently secure and helpful for users looking to debug their credentials process. Just wanted to flag the potential concern, but I'm not requesting changes.

) from e

version = creds.get("Version")
if version is None or version != 1:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if version is None or version != 1:
if version != 1:

Nit: the None check is redundant here since None != 1 returns True.



@pytest.mark.parametrize("command", [[], None])
def test_resolver_invalid_command(command: object):
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you mean to add more test cases here, like a tuple or a space separated string?

I'm not sure why you chose to have a generic name with pymark.test.parametrize, but only one test case

return process


@pytest.mark.asyncio
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: we could make a test class and just have this annotation once (or a few times as it makes sense to separate out the classes) instead of 19 times



@pytest.mark.asyncio
async def test_missing_expiration():
Copy link
Contributor

Choose a reason for hiding this comment

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

How is this different than test_valid_credentials_with_session_token?



@pytest.mark.asyncio
async def test_missing_expiration_and_session_token():
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above, this is similar to test_valid_credentials_without_session_token



@pytest.mark.asyncio
async def test_credentials_with_expiration():
Copy link
Contributor

Choose a reason for hiding this comment

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

Couldn't this be renamed to test_credentials_with_optional_fields and consolidated with test_credentials_with_account_id and also add session token in here?

async def test_process_timeout():
process = AsyncMock()
process.returncode = None
process.communicate = AsyncMock(side_effect=TimeoutError)
Copy link
Contributor

Choose a reason for hiding this comment

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

We are mocking process.communicate, but this error gets raised during wait_for.

Can we get this updated to reflect the expected code path for this error?



@pytest.mark.asyncio
async def test_credentials_with_non_utc_expiration():
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a negative test case for invalid string expirations and another for non-string expirations?



@pytest.mark.asyncio
async def test_missing_access_key_id():
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Should we parametrize this and the next test? Either way is fine, it's just duplicated code.

account_id = creds.get("AccountId")

if isinstance(expiration, str):
dt = datetime.fromisoformat(expiration)
Copy link
Contributor

Choose a reason for hiding this comment

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

What does this look like when we have an invalid string (for instance foo)?

That will raise ValueError: Invalid isoformat string, so maybe we need another try/except here.

async def test_non_zero_exit_code():
process = mock_subprocess(1, b"", b"Process error message")

with patch("asyncio.create_subprocess_exec", return_value=process):
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add an assertion that "Process error message" is in the error value?

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