This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 8599a70e63f NIFI-16137 Added External Assertion support to
JWTBearerOAuth2AccessTokenProvider (#11463)
8599a70e63f is described below
commit 8599a70e63f10691c09821064ae5883bfad68ea1
Author: Pierre Villard <[email protected]>
AuthorDate: Wed Aug 5 21:57:48 2026 +0200
NIFI-16137 Added External Assertion support to
JWTBearerOAuth2AccessTokenProvider (#11463)
Signed-off-by: David Handermann <[email protected]>
---
.../org/apache/nifi/oauth2/AssertionStrategy.java | 51 +++++++++++++
.../oauth2/JWTBearerOAuth2AccessTokenProvider.java | 86 +++++++++++++++++++---
.../JWTBearerOAuth2AccessTokenProviderTest.java | 67 +++++++++++++++++
3 files changed, 193 insertions(+), 11 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/AssertionStrategy.java
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/AssertionStrategy.java
new file mode 100644
index 00000000000..be16dd9d791
--- /dev/null
+++
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/AssertionStrategy.java
@@ -0,0 +1,51 @@
+/*
+ * 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.oauth2;
+
+import org.apache.nifi.components.DescribedValue;
+
+/**
+ * Supported strategies for producing the RFC 7523 JWT assertion presented to
the token endpoint.
+ */
+public enum AssertionStrategy implements DescribedValue {
+ SELF_SIGNED("Self-Signed", "Build and sign the JWT assertion locally using
a Private Key Service."),
+ EXTERNAL_PROVIDER("External Provider", "Use the token from an external
OAuth2AccessTokenProvider directly as the JWT assertion.");
+
+ private final String displayName;
+ private final String description;
+
+ AssertionStrategy(final String displayName, final String description) {
+ this.displayName = displayName;
+ this.description = description;
+ }
+
+ @Override
+ public String getValue() {
+ return name();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+}
diff --git
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java
index 68a12287a06..ca33c40a7af 100644
---
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java
+++
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java
@@ -88,8 +88,12 @@ import javax.net.ssl.X509ExtendedKeyManager;
@SupportsSensitiveDynamicProperties
@Tags({ "oauth2", "provider", "authorization", "access token", "hjwt" })
-@CapabilityDescription("Provides OAuth 2.0 access tokens that can be used as
Bearer authorization header in HTTP requests." +
- " This controller service is for implementing the OAuth 2.0 JWT Bearer
Flow.")
+@CapabilityDescription("""
+ Provides OAuth 2.0 access tokens that can be used as Bearer
authorization header in HTTP requests.
+ This controller service is for implementing the OAuth 2.0 JWT Bearer
Flow. The JWT assertion can either be
+ built and signed locally using a Private Key Service, or supplied by
an external OAuth2AccessTokenProvider,
+ allowing an externally-issued JWT to be chained as the client
assertion presented to the token endpoint.
+ """)
@DynamicProperties({
@DynamicProperty(
name = "CLAIM.JWT claim name",
@@ -118,11 +122,32 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.required(true)
.build();
+ public static final PropertyDescriptor ASSERTION_STRATEGY = new
PropertyDescriptor.Builder()
+ .name("Assertion Strategy")
+ .description("Determines how the RFC 7523 JWT assertion presented
to the Token Endpoint is produced.")
+ .required(true)
+ .allowableValues(AssertionStrategy.class)
+ .defaultValue(AssertionStrategy.SELF_SIGNED)
+ .build();
+
public static final PropertyDescriptor PRIVATE_KEY_SERVICE = new
PropertyDescriptor.Builder()
.name("Private Key Service")
.description("The private key service to use for signing JWTs.")
.identifiesControllerService(PrivateKeyService.class)
.required(true)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
+ .build();
+
+ public static final PropertyDescriptor EXTERNAL_ASSERTION_PROVIDER = new
PropertyDescriptor.Builder()
+ .name("External Assertion Provider")
+ .description("""
+ An OAuth2AccessTokenProvider whose token is used directly
as the JWT assertion. Use this to
+ chain an externally-issued JWT (for example, from another
identity provider's workload identity
+ federation) as the RFC 7523 client assertion presented to
this service's Token Endpoint.
+ """)
+ .identifiesControllerService(OAuth2AccessTokenProvider.class)
+ .required(true)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.EXTERNAL_PROVIDER)
.build();
public static final PropertyDescriptor SIGNING_ALGORITHM = new
PropertyDescriptor.Builder()
@@ -141,6 +166,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
JWSAlgorithm.Ed25519.getName())
.defaultValue(JWSAlgorithm.PS256.getName())
.required(true)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(Validator.VALID)
.build();
@@ -148,6 +174,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.name("Issuer")
.description("The issuer claim (iss) for the JWT.")
.required(false)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
.build();
@@ -156,6 +183,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.name("Subject")
.description("The subject claim (sub) for the JWT.")
.required(false)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
.build();
@@ -164,6 +192,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.name("Audience")
.description("The audience claim (aud) for the JWT.
Space-separated list of audiences if multiple are expected.")
.required(false)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
.build();
@@ -172,6 +201,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.name("Scope")
.description("The scope claim (scope) for the JWT.")
.required(false)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
.build();
@@ -203,6 +233,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.required(true)
.allowableValues("true", "false")
.defaultValue("false")
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(Validator.VALID)
.build();
@@ -224,6 +255,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
value to ${UUID()}.
""")
.required(false)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
.build();
@@ -232,6 +264,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
.name("Key ID")
.description("The ID of the public key used to sign the JWT. It'll
be used as the kid header in the JWT.")
.required(false)
+ .dependsOn(ASSERTION_STRATEGY, AssertionStrategy.SELF_SIGNED)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
.build();
@@ -258,7 +291,9 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
TOKEN_ENDPOINT,
WEB_CLIENT_SERVICE,
+ ASSERTION_STRATEGY,
PRIVATE_KEY_SERVICE,
+ EXTERNAL_ASSERTION_PROVIDER,
SIGNING_ALGORITHM,
ISSUER,
SUBJECT,
@@ -284,6 +319,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
private volatile WebClientService webClientService;
private volatile PrivateKey privateKey;
private volatile X509ExtendedKeyManager keyManager;
+ private volatile OAuth2AccessTokenProvider externalAssertionProvider;
private volatile String tokenEndpoint;
private volatile Duration refreshWindow;
private volatile Duration jwtValidity;
@@ -315,7 +351,9 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
@OnEnabled
public void onEnabled(ConfigurationContext context) {
initProperties(context);
- initJWTSigner();
+ if (externalAssertionProvider == null) {
+ initJWTSigner();
+ }
}
@OnDisabled
@@ -354,6 +392,12 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
protected Collection<ValidationResult> customValidate(ValidationContext
validationContext) {
final List<ValidationResult> validationResults = new
ArrayList<>(super.customValidate(validationContext));
+ final AssertionStrategy strategy =
validationContext.getProperty(ASSERTION_STRATEGY).asAllowableValue(AssertionStrategy.class);
+
+ if (strategy != AssertionStrategy.SELF_SIGNED) {
+ return validationResults;
+ }
+
PrivateKeyService keyService =
validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
String algorithmName =
validationContext.getProperty(SIGNING_ALGORITHM).getValue();
PrivateKey privateKey = keyService.getPrivateKey();
@@ -400,7 +444,9 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
@Override
public List<ConfigVerificationResult> verify(ConfigurationContext context,
ComponentLog verificationLogger, Map<String, String> variables) {
initProperties(context);
- initJWTSigner();
+ if (externalAssertionProvider == null) {
+ initJWTSigner();
+ }
ConfigVerificationResult.Builder builder = new
ConfigVerificationResult.Builder().verificationStepName("Acquire token");
try {
getAccessDetails();
@@ -446,6 +492,22 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
private void acquireAccessDetails() throws URISyntaxException,
JOSEException {
getLogger().debug("New Access Token request started [{}]",
tokenEndpoint);
+ final String assertionValue;
+ if (externalAssertionProvider != null) {
+ assertionValue =
externalAssertionProvider.getAccessDetails().getAccessToken();
+ } else {
+ assertionValue = buildSignedAssertion();
+ }
+
+ Map<String, String> formParams = new HashMap<>();
+ formParams.put("grant_type", grantType);
+ formParams.put(assertion, assertionValue);
+ formParams.putAll(this.formParams);
+
+ requestTokenEndpoint(formParams);
+ }
+
+ private String buildSignedAssertion() throws JOSEException {
final Instant now = Instant.now();
final Date nowDate = Date.from(now);
final Date expirationTime = Date.from(now.plus(jwtValidity));
@@ -495,12 +557,7 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
}
}
- Map<String, String> formParams = new HashMap<>();
- formParams.put("grant_type", grantType);
- formParams.put(assertion, getAssertion(headerBuilder.build(),
claimsSetBuilder.build()));
- formParams.putAll(this.formParams);
-
- requestTokenEndpoint(formParams);
+ return getAssertion(headerBuilder.build(), claimsSetBuilder.build());
}
private String getBase64EncodedSHA256Digest() throws
NoSuchAlgorithmException, CertificateEncodingException {
@@ -565,7 +622,14 @@ public class JWTBearerOAuth2AccessTokenProvider extends
AbstractControllerServic
}
private void initProperties(ConfigurationContext context) {
- privateKey =
context.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class).getPrivateKey();
+ final AssertionStrategy strategy =
context.getProperty(ASSERTION_STRATEGY).asAllowableValue(AssertionStrategy.class);
+ if (strategy == AssertionStrategy.SELF_SIGNED) {
+ privateKey =
context.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class).getPrivateKey();
+ externalAssertionProvider = null;
+ } else {
+ privateKey = null;
+ externalAssertionProvider =
context.getProperty(EXTERNAL_ASSERTION_PROVIDER).asControllerService(OAuth2AccessTokenProvider.class);
+ }
tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
webClientService =
context.getProperty(WEB_CLIENT_SERVICE).asControllerService(WebClientServiceProvider.class).getWebClientService();
refreshWindow = context.getProperty(REFRESH_WINDOW).asDuration();
diff --git
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java
index ce17d6ad2ac..6214aeaebed 100644
---
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java
+++
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java
@@ -58,6 +58,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
@@ -66,6 +67,8 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class JWTBearerOAuth2AccessTokenProviderTest {
+ private static final String EXTERNAL_ASSERTION_PROVIDER_ID =
"externalAssertionProvider";
+
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private ConfigurationContext mockContext;
@@ -275,6 +278,56 @@ class JWTBearerOAuth2AccessTokenProviderTest {
runner.assertValid(provider);
}
+ @Test
+ void testValidationFailsWhenSelfSignedStrategyMissingPrivateKeyService() {
+ // default strategy is Self-Signed; without a Private Key Service
configured, validation must fail
+ final Collection<ValidationResult> validations =
runner.validate(provider);
+ assertTrue(validations.stream().anyMatch(validation ->
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.PRIVATE_KEY_SERVICE.getDisplayName())));
+ }
+
+ @Test
+ void
testValidationFailsWhenExternalProviderStrategyMissingExternalAssertionProvider()
{
+ runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.ASSERTION_STRATEGY,
AssertionStrategy.EXTERNAL_PROVIDER.name());
+
+ final Collection<ValidationResult> validations =
runner.validate(provider);
+ assertTrue(validations.stream().anyMatch(validation ->
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.EXTERNAL_ASSERTION_PROVIDER.getDisplayName())));
+ }
+
+ @Test
+ void testExternalAssertionProviderTokenIsUsedAsAssertion() throws
Exception {
+ setExternalAssertionProviderMock("external-token-value");
+
+ runner.enableControllerService(provider);
+ provider.getAccessDetails();
+
+ // the external provider's token is used directly as the assertion, no
local JWT is built or signed
+ assertNull(provider.getJwsHeader());
+ assertNull(provider.getJwtClaimsSet());
+
+ final Map<String, String> formParams = provider.getFormParams();
+ assertEquals("external-token-value",
formParams.get("customAssertionField"));
+ assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer",
formParams.get("grant_type"));
+ }
+
+ @Test
+ void testVerifySurfacesExternalProviderFailureCause() throws Exception {
+ runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.ASSERTION_STRATEGY,
AssertionStrategy.EXTERNAL_PROVIDER.name());
+
+ final OAuth2AccessTokenProvider externalProvider =
mock(OAuth2AccessTokenProvider.class);
+ lenient().when(externalProvider.getAccessDetails()).thenThrow(new
IllegalStateException("external provider unavailable"));
+
lenient().when(externalProvider.getIdentifier()).thenReturn(EXTERNAL_ASSERTION_PROVIDER_ID);
+
+ runner.addControllerService(EXTERNAL_ASSERTION_PROVIDER_ID,
externalProvider);
+ runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.EXTERNAL_ASSERTION_PROVIDER,
EXTERNAL_ASSERTION_PROVIDER_ID);
+ runner.enableControllerService(externalProvider);
+ runner.enableControllerService(provider);
+
+ final List<ConfigVerificationResult> results = runner.verify(provider,
Map.of());
+ assertEquals(1, results.size());
+ assertEquals(Outcome.FAILED, results.get(0).getOutcome());
+ assertTrue(results.get(0).getExplanation().contains("external provider
unavailable"));
+ }
+
private void setContext() {
runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.ISSUER, "TestIssuer");
runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.SUBJECT, "TestSubject");
@@ -310,6 +363,20 @@ class JWTBearerOAuth2AccessTokenProviderTest {
runner.enableControllerService(keyService);
}
+ private void setExternalAssertionProviderMock(String tokenValue) throws
InitializationException {
+ runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.ASSERTION_STRATEGY,
AssertionStrategy.EXTERNAL_PROVIDER.name());
+
+ final OAuth2AccessTokenProvider externalProvider =
mock(OAuth2AccessTokenProvider.class);
+ final AccessToken token = new AccessToken();
+ token.setAccessToken(tokenValue);
+ lenient().when(externalProvider.getAccessDetails()).thenReturn(token);
+
lenient().when(externalProvider.getIdentifier()).thenReturn(EXTERNAL_ASSERTION_PROVIDER_ID);
+
+ runner.addControllerService(EXTERNAL_ASSERTION_PROVIDER_ID,
externalProvider);
+ runner.setProperty(provider,
JWTBearerOAuth2AccessTokenProvider.EXTERNAL_ASSERTION_PROVIDER,
EXTERNAL_ASSERTION_PROVIDER_ID);
+ runner.enableControllerService(externalProvider);
+ }
+
private class JWTBearerOAuth2AccessTokenProviderForTests extends
JWTBearerOAuth2AccessTokenProvider {
private JWSHeader jwsHeader;
private JWTClaimsSet jwtClaimsSet;