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;

Reply via email to