From da454808be9a7a6df9dfc6a0430777490b4abae6 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Wed, 10 Sep 2025 20:46:24 +0200 Subject: [PATCH 1/2] NIFI-14956 - AWS Credentials Provider - Add support for STS AssumeRoleWithWebIdentity --- .../nifi-aws-processors/pom.xml | 6 + .../AssumeRoleCredentialsStrategy.java | 6 +- .../WebIdentityCredentialsStrategy.java | 214 ++++++++++++++++++ ...SCredentialsProviderControllerService.java | 31 ++- ...dentialsProviderControllerServiceTest.java | 73 ++++++ 5 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/pom.xml b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/pom.xml index f235003dddd4..454c2e01c97a 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/pom.xml +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/pom.xml @@ -31,6 +31,12 @@ nifi-utils + + + org.apache.nifi + nifi-oauth2-provider-api + + org.apache.nifi nifi-listed-entity diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java index 18889b7ad912..287cb0a07ccc 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java @@ -22,6 +22,7 @@ import org.apache.nifi.components.ValidationResult; import org.apache.nifi.context.PropertyContext; import org.apache.nifi.processors.aws.credentials.provider.factory.CredentialsStrategy; +import org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService; import org.apache.nifi.proxy.ProxyConfiguration; import org.apache.nifi.proxy.ProxyConfigurationService; import org.apache.nifi.ssl.SSLContextProvider; @@ -48,7 +49,6 @@ import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_STS_REGION; import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.MAX_SESSION_TIME; - /** * Supports AWS credentials via Assume Role. Assume Role is a derived credential strategy, requiring a primary * credential to retrieve and periodically refresh temporary credentials. @@ -73,6 +73,10 @@ public boolean canCreatePrimaryCredential(final PropertyContext propertyContext) @Override public boolean canCreateDerivedCredential(final PropertyContext propertyContext) { + if (propertyContext.getProperty(AWSCredentialsProviderControllerService.OAUTH2_ACCESS_TOKEN_PROVIDER).isSet()) { + return false; + } + final String assumeRoleArn = propertyContext.getProperty(ASSUME_ROLE_ARN).getValue(); final String assumeRoleName = propertyContext.getProperty(ASSUME_ROLE_NAME).getValue(); if (assumeRoleArn != null && !assumeRoleArn.isEmpty() diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java new file mode 100644 index 000000000000..f674f7e48e1e --- /dev/null +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.processors.aws.credentials.provider.factory.strategies; + +import org.apache.commons.lang3.StringUtils; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.ValidationContext; +import org.apache.nifi.components.ValidationResult; +import org.apache.nifi.context.PropertyContext; +import org.apache.nifi.oauth2.AccessToken; +import org.apache.nifi.oauth2.OAuth2AccessTokenProvider; +import org.apache.nifi.processors.aws.credentials.provider.factory.CredentialsStrategy; +import org.apache.nifi.proxy.ProxyConfiguration; +import org.apache.nifi.proxy.ProxyConfigurationService; +import org.apache.nifi.ssl.SSLContextProvider; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sts.StsClient; +import software.amazon.awssdk.services.sts.StsClientBuilder; +import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest; +import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityResponse; +import software.amazon.awssdk.services.sts.model.Credentials; + +import javax.net.ssl.SSLContext; + +import java.net.Proxy; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_ARN; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_NAME; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_PROXY_CONFIGURATION_SERVICE; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_SSL_CONTEXT_SERVICE; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_STS_ENDPOINT; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_STS_REGION; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.MAX_SESSION_TIME; +import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.OAUTH2_ACCESS_TOKEN_PROVIDER; + +/** + * Supports AWS SDK v2 credentials using STS AssumeRoleWithWebIdentity and an OAuth2/OIDC token + * provided by an {@link OAuth2AccessTokenProvider}. This is a primary strategy for SDK v2 only. + */ +public class WebIdentityCredentialsStrategy extends AbstractCredentialsStrategy implements CredentialsStrategy { + + public WebIdentityCredentialsStrategy() { + super("Web Identity (OIDC)", new PropertyDescriptor[]{ + OAUTH2_ACCESS_TOKEN_PROVIDER, + ASSUME_ROLE_ARN, + ASSUME_ROLE_NAME + }); + } + + @Override + public Collection validate(final ValidationContext validationContext, final CredentialsStrategy primaryStrategy) { + // Avoid cross-strategy validation conflicts: Web Identity reuses Assume Role properties. + // Controller-level validation enforces required combinations when OAuth2 is configured. + return null; + } + + @Override + public AwsCredentialsProvider getAwsCredentialsProvider(final PropertyContext propertyContext) { + final OAuth2AccessTokenProvider tokenProvider = propertyContext.getProperty(OAUTH2_ACCESS_TOKEN_PROVIDER).asControllerService(OAuth2AccessTokenProvider.class); + final String roleArn = propertyContext.getProperty(ASSUME_ROLE_ARN).getValue(); + final String roleSessionName = propertyContext.getProperty(ASSUME_ROLE_NAME).getValue(); + final Integer sessionSeconds = propertyContext.getProperty(MAX_SESSION_TIME).asInteger(); + final String stsRegionId = propertyContext.getProperty(ASSUME_ROLE_STS_REGION).getValue(); + final String stsEndpoint = propertyContext.getProperty(ASSUME_ROLE_STS_ENDPOINT).getValue(); + final SSLContextProvider sslContextProvider = propertyContext.getProperty(ASSUME_ROLE_SSL_CONTEXT_SERVICE).asControllerService(SSLContextProvider.class); + final ProxyConfigurationService proxyConfigurationService = propertyContext.getProperty(ASSUME_ROLE_PROXY_CONFIGURATION_SERVICE).asControllerService(ProxyConfigurationService.class); + + final ApacheHttpClient.Builder httpClientBuilder = ApacheHttpClient.builder(); + + if (sslContextProvider != null) { + final SSLContext sslContext = sslContextProvider.createContext(); + httpClientBuilder.socketFactory(new SSLConnectionSocketFactory(sslContext)); + } + + if (proxyConfigurationService != null) { + final ProxyConfiguration proxyConfiguration = proxyConfigurationService.getConfiguration(); + if (proxyConfiguration.getProxyType() == Proxy.Type.HTTP) { + final software.amazon.awssdk.http.apache.ProxyConfiguration.Builder proxyConfigBuilder = software.amazon.awssdk.http.apache.ProxyConfiguration.builder() + .endpoint(URI.create(String.format("http://%s:%s", proxyConfiguration.getProxyServerHost(), proxyConfiguration.getProxyServerPort()))); + + if (proxyConfiguration.hasCredential()) { + proxyConfigBuilder.username(proxyConfiguration.getProxyUserName()); + proxyConfigBuilder.password(proxyConfiguration.getProxyUserPassword()); + } + + httpClientBuilder.proxyConfiguration(proxyConfigBuilder.build()); + } + } + + final StsClientBuilder stsClientBuilder = StsClient.builder().httpClient(httpClientBuilder.build()); + + if (stsRegionId != null) { + stsClientBuilder.region(Region.of(stsRegionId)); + } + + if (stsEndpoint != null && !stsEndpoint.isEmpty()) { + stsClientBuilder.endpointOverride(URI.create(stsEndpoint)); + } + + final StsClient stsClient = stsClientBuilder.build(); + + return new WebIdentityRefreshingCredentialsProvider(stsClient, tokenProvider, roleArn, roleSessionName, sessionSeconds); + } + + private static final class WebIdentityRefreshingCredentialsProvider implements AwsCredentialsProvider { + private static final Duration SKEW = Duration.ofSeconds(60); + + private final StsClient stsClient; + private final OAuth2AccessTokenProvider oauth2AccessTokenProvider; + private final String roleArn; + private final String roleSessionName; + private final Integer sessionSeconds; + + private volatile AwsSessionCredentials cached; + private volatile Instant expiration; + + private WebIdentityRefreshingCredentialsProvider(final StsClient stsClient, + final OAuth2AccessTokenProvider oauth2AccessTokenProvider, + final String roleArn, + final String roleSessionName, + final Integer sessionSeconds) { + this.stsClient = Objects.requireNonNull(stsClient, "stsClient required"); + this.oauth2AccessTokenProvider = Objects.requireNonNull(oauth2AccessTokenProvider, "OAuth2AccessTokenProvider required"); + this.roleArn = Objects.requireNonNull(roleArn, "roleArn required"); + this.roleSessionName = Objects.requireNonNull(roleSessionName, "roleSessionName required"); + this.sessionSeconds = sessionSeconds; + } + + @Override + public AwsCredentials resolveCredentials() { + final Instant now = Instant.now(); + final AwsSessionCredentials current = cached; + final Instant currentExpiration = expiration; + if (current != null && currentExpiration != null && now.isBefore(currentExpiration.minus(SKEW))) { + return current; + } + + synchronized (this) { + if (cached != null && expiration != null && Instant.now().isBefore(expiration.minus(SKEW))) { + return cached; + } + + final String webIdentityJwt = getWebIdentityToken(); + + final AssumeRoleWithWebIdentityRequest.Builder reqBuilder = AssumeRoleWithWebIdentityRequest.builder() + .roleArn(roleArn) + .roleSessionName(roleSessionName) + .webIdentityToken(webIdentityJwt); + + if (sessionSeconds != null) { + reqBuilder.durationSeconds(sessionSeconds); + } + + final AssumeRoleWithWebIdentityResponse resp = stsClient.assumeRoleWithWebIdentity(reqBuilder.build()); + final Credentials creds = resp.credentials(); + final AwsSessionCredentials sessionCreds = AwsSessionCredentials.create( + creds.accessKeyId(), creds.secretAccessKey(), creds.sessionToken()); + + this.cached = sessionCreds; + this.expiration = creds.expiration(); + return sessionCreds; + } + } + + private String getWebIdentityToken() { + final AccessToken accessToken = oauth2AccessTokenProvider.getAccessDetails(); + if (accessToken == null) { + throw new IllegalStateException("OAuth2AccessTokenProvider returned null AccessToken"); + } + + // Prefer id_token when available + final Map additional = accessToken.getAdditionalParameters(); + if (additional != null) { + final Object idTokenObj = additional.get("id_token"); + if (idTokenObj instanceof String && !((String) idTokenObj).isEmpty()) { + return (String) idTokenObj; + } + } + + final String token = accessToken.getAccessToken(); + + if (StringUtils.isBlank(token)) { + throw new IllegalStateException("No usable token found in AccessToken (id_token or access_token)"); + } + + return token; + } + } +} diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java index 840c958fb22e..9f1020a9ad69 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java @@ -34,6 +34,7 @@ import org.apache.nifi.expression.ExpressionLanguageScope; import org.apache.nifi.migration.PropertyConfiguration; import org.apache.nifi.migration.ProxyServiceMigration; +import org.apache.nifi.oauth2.OAuth2AccessTokenProvider; import org.apache.nifi.processor.util.StandardValidators; import org.apache.nifi.processors.aws.credentials.provider.AwsCredentialsProviderService; import org.apache.nifi.processors.aws.credentials.provider.factory.CredentialsStrategy; @@ -44,6 +45,7 @@ import org.apache.nifi.processors.aws.credentials.provider.factory.strategies.FileCredentialsStrategy; import org.apache.nifi.processors.aws.credentials.provider.factory.strategies.ImplicitDefaultCredentialsStrategy; import org.apache.nifi.processors.aws.credentials.provider.factory.strategies.NamedProfileCredentialsStrategy; +import org.apache.nifi.processors.aws.credentials.provider.factory.strategies.WebIdentityCredentialsStrategy; import org.apache.nifi.proxy.ProxyConfigurationService; import org.apache.nifi.ssl.SSLContextProvider; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -61,7 +63,8 @@ @CapabilityDescription("Defines credentials for Amazon Web Services processors. " + "Uses default credentials without configuration. " + "Default credentials support EC2 instance profile/role, default user profile, environment variables, etc. " + - "Additional options include access key / secret key pairs, credentials file, named profile, and assume role credentials.") + "Additional options include access key / secret key pairs, credentials file, named profile, assume role credentials, " + + "and OAuth2 OIDC Web Identity-based temporary credentials using the same Assume Role properties.") @Tags({ "aws", "credentials", "provider" }) @Restricted( restrictions = { @@ -215,6 +218,13 @@ public class AWSCredentialsProviderControllerService extends AbstractControllerS .dependsOn(ASSUME_ROLE_ARN) .build(); + public static final PropertyDescriptor OAUTH2_ACCESS_TOKEN_PROVIDER = new PropertyDescriptor.Builder() + .name("OAuth2 Access Token Provider") + .description("Controller Service providing OAuth2/OIDC tokens to exchange for AWS temporary credentials using STS AssumeRoleWithWebIdentity.") + .identifiesControllerService(OAuth2AccessTokenProvider.class) + .required(false) + .build(); + private static final List PROPERTY_DESCRIPTORS = List.of( USE_DEFAULT_CREDENTIALS, @@ -230,13 +240,15 @@ public class AWSCredentialsProviderControllerService extends AbstractControllerS ASSUME_ROLE_SSL_CONTEXT_SERVICE, ASSUME_ROLE_PROXY_CONFIGURATION_SERVICE, ASSUME_ROLE_STS_REGION, - ASSUME_ROLE_STS_ENDPOINT + ASSUME_ROLE_STS_ENDPOINT, + OAUTH2_ACCESS_TOKEN_PROVIDER ); private volatile AwsCredentialsProvider credentialsProvider; private final List strategies = List.of( // Primary Credential Strategies + new WebIdentityCredentialsStrategy(), new ExplicitDefaultCredentialsStrategy(), new AccessKeyPairCredentialsStrategy(), new FileCredentialsStrategy(), @@ -321,6 +333,19 @@ protected Collection customValidate(final ValidationContext va } } + final boolean oauth2Configured = validationContext.getProperty(OAUTH2_ACCESS_TOKEN_PROVIDER).isSet(); + if (oauth2Configured) { + final boolean roleArnSet = validationContext.getProperty(ASSUME_ROLE_ARN).isSet(); + final boolean roleNameSet = validationContext.getProperty(ASSUME_ROLE_NAME).isSet(); + if (!roleArnSet || !roleNameSet) { + validationFailureResults.add(new ValidationResult.Builder() + .subject(ASSUME_ROLE_ARN.getDisplayName()) + .valid(false) + .explanation("Web Identity (OIDC) requires both '" + ASSUME_ROLE_ARN.getDisplayName() + "' and '" + ASSUME_ROLE_NAME.getDisplayName() + "' to be set") + .build()); + } + } + return validationFailureResults; } @@ -349,4 +374,4 @@ public static AllowableValue createAllowableValue(final Region region) { public String toString() { return "AWSCredentialsProviderControllerService[id=" + getIdentifier() + "]"; } -} \ No newline at end of file +} diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/test/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerServiceTest.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/test/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerServiceTest.java index 15c08cf464cc..8aa9baced04f 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/test/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerServiceTest.java +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/test/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerServiceTest.java @@ -16,6 +16,9 @@ */ package org.apache.nifi.processors.aws.credentials.provider.service; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.oauth2.AccessToken; +import org.apache.nifi.oauth2.OAuth2AccessTokenProvider; import org.apache.nifi.processors.aws.credentials.provider.AwsCredentialsProviderService; import org.apache.nifi.processors.aws.credentials.provider.PropertiesCredentialsProvider; import org.apache.nifi.processors.aws.s3.FetchS3Object; @@ -31,6 +34,7 @@ import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.CREDENTIALS_FILE; import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.SECRET_KEY; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; public class AWSCredentialsProviderControllerServiceTest { @@ -332,4 +336,73 @@ public void testDefaultAWSCredentialsProviderChainV2() throws Throwable { assertEquals(DefaultCredentialsProvider.class, credentialsProvider.getClass(), "credentials provider should be equal"); } + + @Test + public void testWebIdentityPropertiesAreValidAndServiceEnables() throws Throwable { + final TestRunner runner = TestRunners.newTestRunner(FetchS3Object.class); + final AWSCredentialsProviderControllerService serviceImpl = new AWSCredentialsProviderControllerService(); + + final MockOAuth2AccessTokenProvider tokenProvider = new MockOAuth2AccessTokenProvider(); + runner.addControllerService("oauth2", tokenProvider); + runner.enableControllerService(tokenProvider); + + runner.addControllerService("awsCredentialsProvider", serviceImpl); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.OAUTH2_ACCESS_TOKEN_PROVIDER, "oauth2"); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.ASSUME_ROLE_ARN, "arn:aws:iam::123456789012:role/test"); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.ASSUME_ROLE_NAME, "nifi-test"); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.ASSUME_ROLE_STS_REGION, Region.US_WEST_2.id()); + + runner.enableControllerService(serviceImpl); + runner.assertValid(serviceImpl); + + final AwsCredentialsProviderService service = (AwsCredentialsProviderService) runner.getProcessContext() + .getControllerServiceLookup().getControllerService("awsCredentialsProvider"); + assertNotNull(service); + + final AwsCredentialsProvider credentialsProvider = service.getAwsCredentialsProvider(); + assertNotNull(credentialsProvider); + assertEquals("WebIdentityRefreshingCredentialsProvider", credentialsProvider.getClass().getSimpleName()); + } + + @Test + public void testWebIdentityDoesNotChainAssumeRoleDerivedProvider() throws Throwable { + final TestRunner runner = TestRunners.newTestRunner(FetchS3Object.class); + final AWSCredentialsProviderControllerService serviceImpl = new AWSCredentialsProviderControllerService(); + + final MockOAuth2AccessTokenProvider tokenProvider = new MockOAuth2AccessTokenProvider(); + runner.addControllerService("oauth2", tokenProvider); + runner.enableControllerService(tokenProvider); + + runner.addControllerService("awsCredentialsProvider", serviceImpl); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.OAUTH2_ACCESS_TOKEN_PROVIDER, "oauth2"); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.ASSUME_ROLE_ARN, "arn:aws:iam::123456789012:role/test"); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.ASSUME_ROLE_NAME, "nifi-test"); + runner.setProperty(serviceImpl, AWSCredentialsProviderControllerService.ASSUME_ROLE_STS_REGION, Region.US_WEST_2.id()); + + runner.enableControllerService(serviceImpl); + runner.assertValid(serviceImpl); + + final AwsCredentialsProviderService service = (AwsCredentialsProviderService) runner.getProcessContext() + .getControllerServiceLookup().getControllerService("awsCredentialsProvider"); + assertNotNull(service); + + final AwsCredentialsProvider credentialsProvider = service.getAwsCredentialsProvider(); + assertNotNull(credentialsProvider); + assertFalse(StsAssumeRoleCredentialsProvider.class.isAssignableFrom(credentialsProvider.getClass()), + "Derived AssumeRole should not be chained when OAuth2 (Web Identity) is configured"); + } + + private static final class MockOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider { + @Override + public AccessToken getAccessDetails() { + final AccessToken token = new AccessToken(); + token.setAccessToken("dummy-access-token"); + return token; + } + + @Override + public void refreshAccessDetails() { + // no-op + } + } } From 501c03245578d8e12dfbd3456f344eda41bf26c4 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Thu, 2 Oct 2025 15:23:28 +0200 Subject: [PATCH 2/2] review --- .../AssumeRoleCredentialsStrategy.java | 23 ++++----------- .../WebIdentityCredentialsStrategy.java | 28 +++++++++++-------- ...SCredentialsProviderControllerService.java | 12 ++++++++ 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java index 287cb0a07ccc..19912d94a62e 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/AssumeRoleCredentialsStrategy.java @@ -37,8 +37,8 @@ import javax.net.ssl.SSLContext; import java.net.Proxy; import java.net.URI; -import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_ARN; import static org.apache.nifi.processors.aws.credentials.provider.service.AWSCredentialsProviderControllerService.ASSUME_ROLE_EXTERNAL_ID; @@ -89,22 +89,11 @@ public boolean canCreateDerivedCredential(final PropertyContext propertyContext) @Override public Collection validate(final ValidationContext validationContext, final CredentialsStrategy primaryStrategy) { - final Collection validationFailureResults = new ArrayList<>(); - - final boolean assumeRoleArnIsSet = validationContext.getProperty(ASSUME_ROLE_ARN).isSet(); - - if (assumeRoleArnIsSet) { - final Integer maxSessionTime = validationContext.getProperty(MAX_SESSION_TIME).asInteger(); - - // Session time only b/w 900 to 3600 sec (see software.amazon.awssdk.services.sts.model.AssumeRoleRequest#durationSeconds) - if (maxSessionTime < 900 || maxSessionTime > 3600) { - validationFailureResults.add(new ValidationResult.Builder().valid(false).input(maxSessionTime + "") - .explanation(MAX_SESSION_TIME.getDisplayName() + - " must be between 900 and 3600 seconds").build()); - } - } - - return validationFailureResults; + // Assume Role participates as a derived strategy or reused property group. + // Do not produce cross-strategy validation failures here; required/missing + // fields are enforced by PropertyDescriptor requirements and selected + // strategies, and derived selection is handled separately. + return Collections.emptyList(); } @Override diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java index f674f7e48e1e..7f5c97170eb3 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/factory/strategies/WebIdentityCredentialsStrategy.java @@ -46,6 +46,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Collection; +import java.util.Collections; import java.util.Map; import java.util.Objects; @@ -65,7 +66,7 @@ public class WebIdentityCredentialsStrategy extends AbstractCredentialsStrategy implements CredentialsStrategy { public WebIdentityCredentialsStrategy() { - super("Web Identity (OIDC)", new PropertyDescriptor[]{ + super("Web Identity", new PropertyDescriptor[]{ OAUTH2_ACCESS_TOKEN_PROVIDER, ASSUME_ROLE_ARN, ASSUME_ROLE_NAME @@ -76,7 +77,7 @@ public WebIdentityCredentialsStrategy() { public Collection validate(final ValidationContext validationContext, final CredentialsStrategy primaryStrategy) { // Avoid cross-strategy validation conflicts: Web Identity reuses Assume Role properties. // Controller-level validation enforces required combinations when OAuth2 is configured. - return null; + return Collections.emptyList(); } @Override @@ -165,12 +166,12 @@ public AwsCredentials resolveCredentials() { return cached; } - final String webIdentityJwt = getWebIdentityToken(); + final String webIdentityToken = getWebIdentityToken(); final AssumeRoleWithWebIdentityRequest.Builder reqBuilder = AssumeRoleWithWebIdentityRequest.builder() .roleArn(roleArn) .roleSessionName(roleSessionName) - .webIdentityToken(webIdentityJwt); + .webIdentityToken(webIdentityToken); if (sessionSeconds != null) { reqBuilder.durationSeconds(sessionSeconds); @@ -196,19 +197,22 @@ private String getWebIdentityToken() { // Prefer id_token when available final Map additional = accessToken.getAdditionalParameters(); if (additional != null) { - final Object idTokenObj = additional.get("id_token"); - if (idTokenObj instanceof String && !((String) idTokenObj).isEmpty()) { - return (String) idTokenObj; + final String idToken = (String) additional.get("id_token"); + if (idToken != null) { + if (StringUtils.isBlank(idToken)) { + throw new IllegalStateException("OAuth2AccessTokenProvider returned an empty id_token"); + } else { + return idToken; + } } } - final String token = accessToken.getAccessToken(); - - if (StringUtils.isBlank(token)) { + final String accessTokenValue = accessToken.getAccessToken(); + if (StringUtils.isBlank(accessTokenValue)) { throw new IllegalStateException("No usable token found in AccessToken (id_token or access_token)"); + } else { + return accessTokenValue; } - - return token; } } } diff --git a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java index 9f1020a9ad69..414207feb244 100644 --- a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java +++ b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-processors/src/main/java/org/apache/nifi/processors/aws/credentials/provider/service/AWSCredentialsProviderControllerService.java @@ -223,6 +223,7 @@ public class AWSCredentialsProviderControllerService extends AbstractControllerS .description("Controller Service providing OAuth2/OIDC tokens to exchange for AWS temporary credentials using STS AssumeRoleWithWebIdentity.") .identifiesControllerService(OAuth2AccessTokenProvider.class) .required(false) + .dependsOn(ASSUME_ROLE_ARN) .build(); @@ -346,6 +347,17 @@ protected Collection customValidate(final ValidationContext va } } + if (validationContext.getProperty(ASSUME_ROLE_ARN).isSet()) { + final Integer maxSessionTime = validationContext.getProperty(MAX_SESSION_TIME).asInteger(); + if (maxSessionTime != null && (maxSessionTime < 900 || maxSessionTime > 3600)) { + validationFailureResults.add(new ValidationResult.Builder() + .subject(MAX_SESSION_TIME.getDisplayName()) + .valid(false) + .explanation(MAX_SESSION_TIME.getDisplayName() + " must be between 900 and 3600 seconds") + .build()); + } + } + return validationFailureResults; }