Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions .changes/next-release/feature-AWSSDKforJavav2-f54dd50.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "feature",
"category": "AWS SDK for Java v2",
"contributor": "",
"description": "Add support for account-ID based endpoints when using IMDS credential provider. The SDK now sends the `X-amzn-extended: 1` header on IMDS credential requests and parses the `AccountId` field from the response when available."

@kdaily kdaily Nov 4, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this describe more specifically what will change for users of container credentials (ECS/EKS)? Wouldn't the endpoint change for them as well? If they are allowlisting traffic, adding the account ID to the hostname may break that; the effect on container credential providers isn't called out here.

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.

Good point! ECS and EKS don't return AccountId today, so there's no change for container users right now. If they start returning it, the SDK will pick it up, so good to update the changelog to mention about it, and added tests for container credentials as well for this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Might also be worth reiterating how to opt out (if we did that before when adding support).

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.

Added it!

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
Expand Down Expand Up @@ -76,6 +77,8 @@ public final class InstanceProfileCredentialsProvider
private static final String TOKEN_RESOURCE = "/latest/api/token";
private static final String EC2_METADATA_TOKEN_TTL_HEADER = "x-aws-ec2-metadata-token-ttl-seconds";
private static final String DEFAULT_TOKEN_TTL = "21600";
private static final String EXTENDED_CREDENTIALS_HEADER = "X-amzn-extended";
private static final String EXTENDED_CREDENTIALS_VALUE = "1";

private final Clock clock;
private final String endpoint;
Expand Down Expand Up @@ -225,7 +228,7 @@ private ResourcesEndpointProvider createEndpointProvider() {
return StaticResourcesEndpointProvider.builder()
.endpoint(URI.create(imdsHostname + SECURITY_CREDENTIALS_RESOURCE
+ securityCredentials[0]))
.headers(getTokenHeaders(token))
.headers(getCredentialHeaders(token))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not introduced by this PR, but related. createEndpointProvider() calls getSecurityCredentials() on every refresh, so the profile name is looked up each time. The spec expects the name to be stored after the first lookup. The shared test suite expects one profile GET followed by two credential GETs, so it would fail here. Happy for this to be a follow-up issue rather than block this PR.

@S-Saranya1 S-Saranya1 Nov 5, 2026 •

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.

Yeah we are not implementing profile caching currently in Java, didn't merge that with this change as well, will do that as a followup.

.connectionTimeout(Duration.ofMillis(
this.configProvider.serviceTimeout()))
.build();
Expand Down Expand Up @@ -320,6 +323,19 @@ private Map<String, String> getTokenHeaders(String metadataToken) {
return Collections.singletonMap(EC2_METADATA_TOKEN_HEADER, metadataToken);
}

/**
* Returns headers for the credential GET request. Includes the extended credentials header to signal IMDS to
* return the AccountId field in the response, and the IMDSv2 token if available.
*/
private Map<String, String> getCredentialHeaders(String metadataToken) {
Map<String, String> headers = new HashMap<>();
if (metadataToken != null) {
headers.put(EC2_METADATA_TOKEN_HEADER, metadataToken);
}
headers.put(EXTENDED_CREDENTIALS_HEADER, EXTENDED_CREDENTIALS_VALUE);
return Collections.unmodifiableMap(headers);
}

@Override
public Builder toBuilder() {
return new BuilderImpl(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public LoadedCredentials loadCredentials(ResourcesEndpointProvider endpoint) {
JsonNode secretKey = node.get("SecretAccessKey");
JsonNode token = node.get("Token");
JsonNode expiration = node.get("Expiration");
JsonNode accountId = node.get("AccountId");

Validate.notNull(accessKey, "Failed to load access key from metadata service.");
Validate.notNull(secretKey, "Failed to load secret key from metadata service.");
Expand All @@ -72,6 +73,7 @@ public LoadedCredentials loadCredentials(ResourcesEndpointProvider endpoint) {
secretKey.text(),
token != null ? token.text() : null,
expiration != null ? expiration.text() : null,
accountId != null ? accountId.text() : null,
providerName);
} catch (SdkClientException e) {
throw e;
Expand All @@ -88,13 +90,16 @@ public static final class LoadedCredentials {
private final String secretKey;
private final String token;
private final Instant expiration;
private final String accountId;
private final String providerName;

private LoadedCredentials(String accessKeyId, String secretKey, String token, String expiration, String providerName) {
private LoadedCredentials(String accessKeyId, String secretKey, String token, String expiration,
String accountId, String providerName) {
this.accessKeyId = Validate.paramNotBlank(accessKeyId, "accessKeyId");
this.secretKey = Validate.paramNotBlank(secretKey, "secretKey");
this.token = token;
this.expiration = expiration == null ? null : parseExpiration(expiration);
this.accountId = accountId;
this.providerName = providerName;
}

Expand All @@ -105,11 +110,13 @@ public AwsCredentials getAwsCredentials() {
.secretAccessKey(secretKey)
.sessionToken(token)
.expirationTime(expiration)
.accountId(accountId)
.providerName(providerName)
.build() :
AwsBasicCredentials.builder()
.accessKeyId(accessKeyId)
.secretAccessKey(secretKey)
.accountId(accountId)
.providerName(providerName)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,24 @@ public class HttpCredentialsLoaderTest {

private static String successResponseWithInvalidBody;

private static String successResponseWithAccountId;

private static String successResponseWithUnexpectedFields;

@BeforeClass
public static void setup() throws IOException {
try (InputStream successInputStream = HttpCredentialsLoaderTest.class.getResourceAsStream
("/resources/wiremock/successResponse.json");
InputStream responseWithInvalidBodyInputStream = HttpCredentialsLoaderTest.class.getResourceAsStream
("/resources/wiremock/successResponseWithInvalidBody.json")) {
("/resources/wiremock/successResponseWithInvalidBody.json");
InputStream responseWithAccountIdInputStream = HttpCredentialsLoaderTest.class.getResourceAsStream
("/resources/wiremock/successResponseWithAccountId.json");
InputStream responseWithUnexpectedFieldsInputStream = HttpCredentialsLoaderTest.class.getResourceAsStream
("/resources/wiremock/successResponseWithUnexpectedFields.json")) {
successResponse = IoUtils.toUtf8String(successInputStream);
successResponseWithInvalidBody = IoUtils.toUtf8String(responseWithInvalidBodyInputStream);
successResponseWithAccountId = IoUtils.toUtf8String(responseWithAccountIdInputStream);
successResponseWithUnexpectedFields = IoUtils.toUtf8String(responseWithUnexpectedFieldsInputStream);
}
}

Expand All @@ -76,6 +86,58 @@ public void testLoadCredentialsParsesJsonResponseProperly() {
assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID");
assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
assertThat(credentials.sessionToken()).isEqualTo("TOKEN_TOKEN_TOKEN");
assertThat(credentials.accountId()).isNotPresent();
}

/**
* Test that loadCredentials parses AccountId when present in the response.
*/
@Test
public void testLoadCredentialsParsesAccountIdWhenPresent() {
stubForSuccessResponseWithCustomBody(successResponseWithAccountId);

HttpCredentialsLoader credentialsProvider = HttpCredentialsLoader.create(PROVIDER_NAME);
AwsSessionCredentials credentials = (AwsSessionCredentials) credentialsProvider.loadCredentials(testEndpointProvider())
.getAwsCredentials();

assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID");
assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
assertThat(credentials.sessionToken()).isEqualTo("TOKEN_TOKEN_TOKEN");
assertThat(credentials.accountId()).isPresent().hasValue("123456789012");
}

/**
* Test that loadCredentials succeeds without AccountId when it's absent from the response.
*/
@Test
public void testLoadCredentialsSucceedsWithoutAccountId() {
stubForSuccessResponseWithCustomBody(successResponse);

HttpCredentialsLoader credentialsProvider = HttpCredentialsLoader.create(PROVIDER_NAME);
AwsSessionCredentials credentials = (AwsSessionCredentials) credentialsProvider.loadCredentials(testEndpointProvider())
.getAwsCredentials();

assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID");
assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
assertThat(credentials.accountId()).isNotPresent();
}

/**
* Test that loadCredentials does not fail when response contains unexpected/unknown fields.
* This verifies the SEP requirement: "Parsing MUST NOT fail in the presence of unexpected fields."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: reference to SEP.

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.

Removed!

*/
@Test
public void testLoadCredentialsIgnoresUnexpectedFields() {
stubForSuccessResponseWithCustomBody(successResponseWithUnexpectedFields);

HttpCredentialsLoader credentialsProvider = HttpCredentialsLoader.create(PROVIDER_NAME);
AwsSessionCredentials credentials = (AwsSessionCredentials) credentialsProvider.loadCredentials(testEndpointProvider())
.getAwsCredentials();

assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID");
assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
assertThat(credentials.sessionToken()).isEqualTo("TOKEN_TOKEN_TOKEN");
assertThat(credentials.accountId()).isPresent().hasValue("123456789012");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ public class InstanceProfileCredentialsProviderTest {
private static final String STUB_CREDENTIALS = "{\"AccessKeyId\":\"ACCESS_KEY_ID\",\"SecretAccessKey\":\"SECRET_ACCESS_KEY\","
+ "\"Expiration\":\"" + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1)))
+ "\"}";
private static final String STUB_CREDENTIALS_WITH_ACCOUNT_ID =
"{\"AccessKeyId\":\"ACCESS_KEY_ID\",\"SecretAccessKey\":\"SECRET_ACCESS_KEY\","
+ "\"Token\":\"TOKEN_TOKEN_TOKEN\","
+ "\"Expiration\":\"" + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1)))
+ "\",\"AccountId\":\"123456789012\"}";
private static final String TOKEN_HEADER = "x-aws-ec2-metadata-token";
private static final String EXTENDED_CREDENTIALS_HEADER = "X-amzn-extended";
private static final String USER_AGENT_HEADER = "User-Agent";
private static final String TOKEN_STUB = "some-token";
private static final String PROFILE_NAME = "some-profile";
Expand Down Expand Up @@ -161,6 +167,47 @@ void resolveCredentials_usesTokenByDefault() {
verifyImdsCallWithToken();
}

@Test
void resolveCredentials_sendsExtendedHeaderOnCredentialGet() {
stubSecureCredentialsResponse(aResponse().withBody(STUB_CREDENTIALS_WITH_ACCOUNT_ID));
InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build();
provider.resolveCredentials();

// Verify extended header is sent on credential GET
WireMock.verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + PROFILE_NAME))
.withHeader(EXTENDED_CREDENTIALS_HEADER, equalTo("1")));

// Verify extended header is NOT sent on profile discovery GET
WireMock.verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))
.withoutHeader(EXTENDED_CREDENTIALS_HEADER));

// Verify extended header is NOT sent on token PUT
WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH))
.withoutHeader(EXTENDED_CREDENTIALS_HEADER));
}

@Test
void resolveCredentials_parsesAccountIdWhenPresent() {
stubSecureCredentialsResponse(aResponse().withBody(STUB_CREDENTIALS_WITH_ACCOUNT_ID));
InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build();
AwsCredentials credentials = provider.resolveCredentials();

assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID");
assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
assertThat(credentials.accountId()).isPresent().hasValue("123456789012");
}

@Test
void resolveCredentials_succeedsWithoutAccountId() {
stubSecureCredentialsResponse(aResponse().withBody(STUB_CREDENTIALS));
InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build();
AwsCredentials credentials = provider.resolveCredentials();

assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID");
assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
assertThat(credentials.accountId()).isNotPresent();
}

@Test
void resolveCredentials_WhenConnectionDelaySetToHighValue() {
environmentVariableHelper.set(SdkSystemSetting.AWS_METADATA_SERVICE_TIMEOUT, "10");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"AccessKeyId": "ACCESS_KEY_ID",
"SecretAccessKey": "SECRET_ACCESS_KEY",
"Token": "TOKEN_TOKEN_TOKEN",
"Expiration": "3000-05-03T04:55:54Z",
"AccountId": "123456789012"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"AccessKeyId": "ACCESS_KEY_ID",
"SecretAccessKey": "SECRET_ACCESS_KEY",
"Token": "TOKEN_TOKEN_TOKEN",
"Expiration": "3000-05-03T04:55:54Z",
"AccountId": "123456789012",
"UnexpectedElement1": {
"Name": "ignore-me-1"
},
"AnotherUnexpected": "should-be-ignored"
}
Loading
Loading