You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@nifi.apache.org by "pvillard31 (via GitHub)" <gi...@apache.org> on 2025/03/21 19:26:13 UTC

[PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

pvillard31 opened a new pull request, #9816:
URL: https://github.com/apache/nifi/pull/9816

   # Summary
   
   [NIFI-14380](https://issues.apache.org/jira/browse/NIFI-14380) - OAuth 2.0 Controller Service to support JWT Bearer Flow
   
   This feature has been tested against
   - Microsoft Sharepoint & MSGraph APIs
   - Box
   - Salesforce
   - Google Identity for Google Cloud
   
   Configuration settings for each scenario can be found in the additional details documentation of the controller service.
   
   # Tracking
   
   Please complete the following tracking steps prior to pull request creation.
   
   ### Issue Tracking
   
   - [ ] [Apache NiFi Jira](https://issues.apache.org/jira/browse/NIFI) issue created
   
   ### Pull Request Tracking
   
   - [ ] Pull Request title starts with Apache NiFi Jira issue number, such as `NIFI-00000`
   - [ ] Pull Request commit message starts with Apache NiFi Jira issue number, as such `NIFI-00000`
   
   ### Pull Request Formatting
   
   - [ ] Pull Request based on current revision of the `main` branch
   - [ ] Pull Request refers to a feature branch with one commit containing changes
   
   # Verification
   
   Please indicate the verification steps performed prior to pull request creation.
   
   ### Build
   
   - [ ] Build completed using `mvn clean install -P contrib-check`
     - [ ] JDK 21
   
   ### Licensing
   
   - [ ] New dependencies are compatible with the [Apache License 2.0](https://apache.org/licenses/LICENSE-2.0) according to the [License Policy](https://www.apache.org/legal/resolved.html)
   - [ ] New dependencies are documented in applicable `LICENSE` and `NOTICE` files
   
   ### Documentation
   
   - [ ] Documentation formatting appears as expected in rendered files
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on PR #9816:
URL: https://github.com/apache/nifi/pull/9816#issuecomment-2778604402

   Pushed a commit to leverage the improvements that came with #9841.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2026620052


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java:
##########
@@ -0,0 +1,300 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class JWTBearerOAuth2AccessTokenProviderTest {
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ConfigurationContext mockContext;
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ValidationContext mockValidationContext;
+
+    @Mock
+    private PrivateKeyService mockKeyService;
+
+    @Mock
+    private WebClientServiceProvider mockWebClientServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+
+    @BeforeEach
+    void setUp() {
+        // Initialize mocks
+        MockitoAnnotations.openMocks(this);
+        provider = new JWTBearerOAuth2AccessTokenProviderForTests();
+
+        // Mock PropertyValue for WEB_CLIENT_SERVICE
+        PropertyValue webClientServicePropertyValue = mock(PropertyValue.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE))
+                .thenReturn(webClientServicePropertyValue);
+
+        // Mock WebClientService
+        when(webClientServicePropertyValue.asControllerService(WebClientServiceProvider.class))
+                .thenReturn(mockWebClientServiceProvider);
+
+        when(mockWebClientServiceProvider.getWebClientService()).thenReturn(mockWebClientService);
+    }
+
+    @Test
+    void testClaimsAreSetProperly() throws Exception {
+        // Setup context
+        setContext();
+
+        setPrivateKeyMock(RSAPrivateKey.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM).getValue()).thenReturn(JWSAlgorithm.RS256.getName());

Review Comment:
   Pushed a commit where I'm using the test runner instead. Not ideal as I can't call the customValidate directly and specifically check what is making the controller service invalid but that could be a follow up improvement in the test framework.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2049277478


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);

Review Comment:
   Fair enough, pushed a commit to have a better logic there



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025105601


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java:
##########
@@ -0,0 +1,300 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class JWTBearerOAuth2AccessTokenProviderTest {
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ConfigurationContext mockContext;
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ValidationContext mockValidationContext;
+
+    @Mock
+    private PrivateKeyService mockKeyService;
+
+    @Mock
+    private WebClientServiceProvider mockWebClientServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+
+    @BeforeEach
+    void setUp() {
+        // Initialize mocks
+        MockitoAnnotations.openMocks(this);

Review Comment:
   This should be removed and replaced with the `ExtendWith(MockitoExtension.class)` annotation on the class.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/pom.xml:
##########
@@ -23,6 +23,11 @@
     <artifactId>nifi-oauth2-provider-service</artifactId>
     <packaging>jar</packaging>
     <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-web-security</artifactId>

Review Comment:
   The `nifi-web-security` module is a Framework dependency that should not be included.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/pom.xml:
##########
@@ -27,4 +27,17 @@
         <module>nifi-oauth2-provider-service</module>
         <module>nifi-oauth2-provider-nar</module>
     </modules>
+    <repositories>
+        <!-- Shibboleth Repository required for OpenSAML -->

Review Comment:
   The Controller Service does not use OpenSAML, so is this required? It seems like it could be removed with the removal of `nifi-web-security`.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;

Review Comment:
   Instead of importing this class from the `nifi-web-security` module, a separate copy should be placed in this module to avoid the dependency.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        getLogger().warn("No private key found, setting x5t header to null.");

Review Comment:
   As above, shouldn't this condition throw an exception?



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");

Review Comment:
   It is best to avoid ending log messages with a period character. However, shouldn't this throw an exception instead of logging a warning?
   ```suggestion
                       getLogger().warn("No key alias found, setting x5t header to null");
   ```



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java:
##########
@@ -0,0 +1,300 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class JWTBearerOAuth2AccessTokenProviderTest {
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ConfigurationContext mockContext;
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ValidationContext mockValidationContext;
+
+    @Mock
+    private PrivateKeyService mockKeyService;
+
+    @Mock
+    private WebClientServiceProvider mockWebClientServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+
+    @BeforeEach
+    void setUp() {
+        // Initialize mocks
+        MockitoAnnotations.openMocks(this);
+        provider = new JWTBearerOAuth2AccessTokenProviderForTests();
+
+        // Mock PropertyValue for WEB_CLIENT_SERVICE
+        PropertyValue webClientServicePropertyValue = mock(PropertyValue.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE))
+                .thenReturn(webClientServicePropertyValue);
+
+        // Mock WebClientService
+        when(webClientServicePropertyValue.asControllerService(WebClientServiceProvider.class))
+                .thenReturn(mockWebClientServiceProvider);
+
+        when(mockWebClientServiceProvider.getWebClientService()).thenReturn(mockWebClientService);
+    }
+
+    @Test
+    void testClaimsAreSetProperly() throws Exception {
+        // Setup context
+        setContext();
+
+        setPrivateKeyMock(RSAPrivateKey.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM).getValue()).thenReturn(JWSAlgorithm.RS256.getName());

Review Comment:
   Instead of mocking the context calls, it seems like it would be cleaner to use the TestRunner.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/resources/docs/org.apache.nifi.oauth2.JWTBearerOAuth2AccessTokenProvider/additionalDetails.md:
##########
@@ -0,0 +1,112 @@
+<!--
+  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.
+-->
+
+# JWT Bearer OAuth 2.0 Access Token Provider
+
+## Description
+
+The `JWTBearerOAuth2AccessTokenProvider` provides an implementation of the `OAuth2AccessTokenProvider` in order to support
+the JWT Bearer Flow.
+
+## Configuration Details
+
+Every service exposing APIs where the OAuth 2.0 JWT Bearer Flow is used for authentication may have some nuances in
+terms of configuration and requirements for the private key used to sign the JWT. For this reason, this controller
+service supports sensitive dynamic properties providing a way to specify custom JWT claims (using dynamic properties
+with a key prefixed by `CLAIM.`) as well as custom form parameters for the request against the access token API
+(using dynamic properties with a key prefixed by `FORM.`).
+
+Below are some configuration examples for some well known SaaS solutions.
+
+### Google Identity ([source](https://developers.google.com/identity/protocols/oauth2/service-account#httprest))
+
+| Property name/key | Property value |
+| --- | --- |
+| Token Endpoint | `https://oauth2.googleapis.com/token` |

Review Comment:
   It would be helpful to format this table using spaces to align columns for easier readability in the source Markdown.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")

Review Comment:
   Given that this implementation is `JWTBearer`, should the `Grant Type` be configurable?



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

Review Comment:
   Recommend moving this after the property descriptors.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")

Review Comment:
   Is there any reason not to set this to `JWT` for all tokens as described in [RFC 7519](https://www.rfc-editor.org/rfc/rfc7519#section-5.1)?



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")

Review Comment:
   Is there any reason not to set the `nbf` claim? It seems like it should always be included.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())

Review Comment:
   Recommend formatting these values as one per line for readability.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")

Review Comment:
   Perhaps using the more descriptive name would be helpful?
   ```suggestion
               .name("Set JWT Header X.509 Certificate Thumbprint")
   ```



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;

Review Comment:
   Having multiple nested `return` statements makes this method difficult to follow. Recommend refactoring to a single return and adjust the conditionals accordingly.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {

Review Comment:
   Minor convention recommendation:
   ```suggestion
           } catch (final Exception e) {
   ```



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        getLogger().warn("No private key found, setting x5t header to null.");
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            getLogger().warn("No certificates found");
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                getLogger().warn("Failed to set x5t header, setting to null.", e);

Review Comment:
   See above notes on exception and message formatting.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        getLogger().warn("No private key found, setting x5t header to null.");
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            getLogger().warn("No certificates found");
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                getLogger().warn("Failed to set x5t header, setting to null.", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", ContentType.APPLICATION_JSON.getType())
+                .header("Content-Type", ContentType.APPLICATION_URLENCODED.getType())
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve access details. Status code: %s, response body: %s".formatted(

Review Comment:
   Recommend adjusting the formatting
   ```suggestion
                   final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
   ```



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        getLogger().warn("No private key found, setting x5t header to null.");
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            getLogger().warn("No certificates found");
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                getLogger().warn("Failed to set x5t header, setting to null.", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", ContentType.APPLICATION_JSON.getType())
+                .header("Content-Type", ContentType.APPLICATION_URLENCODED.getType())
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve access details. Status code: %s, response body: %s".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);
+            return Optional.empty();
+        }
+    }
+
+    private void getProperties(ConfigurationContext context) {

Review Comment:
   This seems like it should be named `setProperties()` since it doesn't return anything.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);

Review Comment:
   ```suggestion
                   throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2049198330


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);

Review Comment:
   Good point, in this case, I recommend removing this warning. It would probably be better to throw the exception, catch it in the calling method, and throw it as the cause of the `AccessTokenRetrievalException`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory merged PR #9816:
URL: https://github.com/apache/nifi/pull/9816


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025302083


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")

Review Comment:
   My bad as well :) tested again all 4 services and they are all fine with NBF being set. Will remove.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on PR #9816:
URL: https://github.com/apache/nifi/pull/9816#issuecomment-2773296809

   Thanks for the initial review @exceptionfactory - I pushed a commit to address your comments.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025212956


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")

Review Comment:
   Based on the documentation, there are implementations not expecting it and where it fails if it is set... I could make the tests again to tell you again which test implementation exactly does not like it when set.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025269315


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java:
##########
@@ -0,0 +1,300 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class JWTBearerOAuth2AccessTokenProviderTest {
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ConfigurationContext mockContext;
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ValidationContext mockValidationContext;
+
+    @Mock
+    private PrivateKeyService mockKeyService;
+
+    @Mock
+    private WebClientServiceProvider mockWebClientServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+
+    @BeforeEach
+    void setUp() {
+        // Initialize mocks
+        MockitoAnnotations.openMocks(this);
+        provider = new JWTBearerOAuth2AccessTokenProviderForTests();
+
+        // Mock PropertyValue for WEB_CLIENT_SERVICE
+        PropertyValue webClientServicePropertyValue = mock(PropertyValue.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE))
+                .thenReturn(webClientServicePropertyValue);
+
+        // Mock WebClientService
+        when(webClientServicePropertyValue.asControllerService(WebClientServiceProvider.class))
+                .thenReturn(mockWebClientServiceProvider);
+
+        when(mockWebClientServiceProvider.getWebClientService()).thenReturn(mockWebClientService);
+    }
+
+    @Test
+    void testClaimsAreSetProperly() throws Exception {
+        // Setup context
+        setContext();
+
+        setPrivateKeyMock(RSAPrivateKey.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM).getValue()).thenReturn(JWSAlgorithm.RS256.getName());

Review Comment:
   I felt like using the TestRunner would make things more complicated in the end as I would need to simulate a processor that references the controller service to actually test the access token retrieval.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025289159


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")

Review Comment:
   My bad, Salesforce seems to be OK when the header type is set, so I'll remove this property



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2049112548


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")

Review Comment:
   Multi line string could be used.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();

Review Comment:
   It seems like the URL and Web Client Service properties should be moved near the top along with other required properties.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);

Review Comment:
   It would be helpful to include the HTTP Status Code, and the URL if available for troubleshooting this warning.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);
+            return Optional.empty();
+        }
+    }
+
+    private void initProperties(ConfigurationContext context) {
+        privateKey = context.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class).getPrivateKey();
+        tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
+        webClientService = context.getProperty(WEB_CLIENT_SERVICE).asControllerService(WebClientServiceProvider.class).getWebClientService();
+        refreshWindow = context.getProperty(REFRESH_WINDOW).asDuration();
+        jwtValidity = context.getProperty(JWT_VALIDITY).asDuration();
+        tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
+        algorithmName = context.getProperty(SIGNING_ALGORITHM).getValue();
+        headerX5T = context.getProperty(HEADER_X5T).asBoolean();
+        grantType = context.getProperty(GRANT_TYPE).evaluateAttributeExpressions().getValue();
+        assertion = context.getProperty(ASSERTION).evaluateAttributeExpressions().getValue();
+
+        if (context.getProperty(ISSUER).isSet()) {
+            issuer = context.getProperty(ISSUER).evaluateAttributeExpressions().getValue();
+        } else {
+            issuer = null;
+        }
+
+        if (context.getProperty(SUBJECT).isSet()) {
+            subject = context.getProperty(SUBJECT).evaluateAttributeExpressions().getValue();
+        } else {
+            subject = null;
+        }
+
+        if (context.getProperty(AUDIENCE).isSet()) {
+            audience = context.getProperty(AUDIENCE).evaluateAttributeExpressions().getValue();
+        } else {
+            audience = null;
+        }
+
+        if (context.getProperty(SCOPE).isSet()) {
+            scope = context.getProperty(SCOPE).evaluateAttributeExpressions().getValue();
+        } else {
+            scope = null;
+        }
+
+        if (context.getProperty(JTI).isSet()) {
+            jti = context.getProperty(JTI).evaluateAttributeExpressions().getValue();
+        } else {
+            jti = null;
+        }
+
+        if (context.getProperty(KID).isSet()) {
+            kid = context.getProperty(KID).evaluateAttributeExpressions().getValue();
+        } else {
+            kid = null;
+        }
+
+        if (context.getProperty(SSL_CONTEXT_PROVIDER).isSet()) {
+            SSLContextProvider sslContextProvider = context.getProperty(SSL_CONTEXT_PROVIDER).asControllerService(SSLContextProvider.class);
+            keyManager = sslContextProvider.createKeyManager().orElseThrow(() -> new IllegalStateException("KeyManager not available"));
+        }
+
+        customClaims = new HashMap<>();
+        formParams = new HashMap<>();
+        for (PropertyDescriptor descriptor : context.getProperties().keySet()) {
+            if (descriptor.isDynamic()) {
+                if (descriptor.getName().startsWith(CLAIM_PREFIX)) {
+                    customClaims.put(StringUtils.substringAfter(descriptor.getName(), CLAIM_PREFIX),
+                            context.getProperty(descriptor).evaluateAttributeExpressions().getValue());
+                } else if (descriptor.getName().startsWith(FORM_PREFIX)) {
+                    formParams.put(StringUtils.substringAfter(descriptor.getName(), FORM_PREFIX),
+                            URLEncoder.encode(context.getProperty(descriptor).evaluateAttributeExpressions().getValue(), StandardCharsets.UTF_8));
+                }
+            }
+        }
+    }
+
+    private void initJWTSigner() {
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                signer = new RSASSASigner((RSAPrivateKey) privateKey);
+            }
+        } else if (privateKey instanceof ECPrivateKey) {

Review Comment:
   ```suggestion
           } else if (privateKey instanceof ECPrivateKey ecPrivateKey) {
   ```



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")

Review Comment:
   This could be reformatted using a multi line string instead of using concatenation.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);
+            return Optional.empty();
+        }
+    }
+
+    private void initProperties(ConfigurationContext context) {
+        privateKey = context.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class).getPrivateKey();
+        tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
+        webClientService = context.getProperty(WEB_CLIENT_SERVICE).asControllerService(WebClientServiceProvider.class).getWebClientService();
+        refreshWindow = context.getProperty(REFRESH_WINDOW).asDuration();
+        jwtValidity = context.getProperty(JWT_VALIDITY).asDuration();
+        tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
+        algorithmName = context.getProperty(SIGNING_ALGORITHM).getValue();
+        headerX5T = context.getProperty(HEADER_X5T).asBoolean();
+        grantType = context.getProperty(GRANT_TYPE).evaluateAttributeExpressions().getValue();
+        assertion = context.getProperty(ASSERTION).evaluateAttributeExpressions().getValue();
+
+        if (context.getProperty(ISSUER).isSet()) {
+            issuer = context.getProperty(ISSUER).evaluateAttributeExpressions().getValue();
+        } else {
+            issuer = null;
+        }
+
+        if (context.getProperty(SUBJECT).isSet()) {
+            subject = context.getProperty(SUBJECT).evaluateAttributeExpressions().getValue();
+        } else {
+            subject = null;
+        }
+
+        if (context.getProperty(AUDIENCE).isSet()) {
+            audience = context.getProperty(AUDIENCE).evaluateAttributeExpressions().getValue();
+        } else {
+            audience = null;
+        }
+
+        if (context.getProperty(SCOPE).isSet()) {
+            scope = context.getProperty(SCOPE).evaluateAttributeExpressions().getValue();
+        } else {
+            scope = null;
+        }
+
+        if (context.getProperty(JTI).isSet()) {
+            jti = context.getProperty(JTI).evaluateAttributeExpressions().getValue();
+        } else {
+            jti = null;
+        }
+
+        if (context.getProperty(KID).isSet()) {
+            kid = context.getProperty(KID).evaluateAttributeExpressions().getValue();
+        } else {
+            kid = null;
+        }
+
+        if (context.getProperty(SSL_CONTEXT_PROVIDER).isSet()) {
+            SSLContextProvider sslContextProvider = context.getProperty(SSL_CONTEXT_PROVIDER).asControllerService(SSLContextProvider.class);
+            keyManager = sslContextProvider.createKeyManager().orElseThrow(() -> new IllegalStateException("KeyManager not available"));
+        }
+
+        customClaims = new HashMap<>();
+        formParams = new HashMap<>();
+        for (PropertyDescriptor descriptor : context.getProperties().keySet()) {
+            if (descriptor.isDynamic()) {
+                if (descriptor.getName().startsWith(CLAIM_PREFIX)) {
+                    customClaims.put(StringUtils.substringAfter(descriptor.getName(), CLAIM_PREFIX),
+                            context.getProperty(descriptor).evaluateAttributeExpressions().getValue());
+                } else if (descriptor.getName().startsWith(FORM_PREFIX)) {
+                    formParams.put(StringUtils.substringAfter(descriptor.getName(), FORM_PREFIX),
+                            URLEncoder.encode(context.getProperty(descriptor).evaluateAttributeExpressions().getValue(), StandardCharsets.UTF_8));
+                }
+            }
+        }
+    }
+
+    private void initJWTSigner() {
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {

Review Comment:
   Is the algorithmName check required here? It should be enforced through validation, and the `RSASSASigner` should also check the key type.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))

Review Comment:
   Recommend declaration `expirationTime` as a separate variable for readability and potential debugging.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }

Review Comment:
   Recommend moving the digest calculation to a private method.



##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);
+            return Optional.empty();
+        }
+    }
+
+    private void initProperties(ConfigurationContext context) {
+        privateKey = context.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class).getPrivateKey();
+        tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
+        webClientService = context.getProperty(WEB_CLIENT_SERVICE).asControllerService(WebClientServiceProvider.class).getWebClientService();
+        refreshWindow = context.getProperty(REFRESH_WINDOW).asDuration();
+        jwtValidity = context.getProperty(JWT_VALIDITY).asDuration();
+        tokenEndpoint = context.getProperty(TOKEN_ENDPOINT).getValue();
+        algorithmName = context.getProperty(SIGNING_ALGORITHM).getValue();
+        headerX5T = context.getProperty(HEADER_X5T).asBoolean();
+        grantType = context.getProperty(GRANT_TYPE).evaluateAttributeExpressions().getValue();
+        assertion = context.getProperty(ASSERTION).evaluateAttributeExpressions().getValue();
+
+        if (context.getProperty(ISSUER).isSet()) {
+            issuer = context.getProperty(ISSUER).evaluateAttributeExpressions().getValue();
+        } else {
+            issuer = null;
+        }
+
+        if (context.getProperty(SUBJECT).isSet()) {
+            subject = context.getProperty(SUBJECT).evaluateAttributeExpressions().getValue();
+        } else {
+            subject = null;
+        }
+
+        if (context.getProperty(AUDIENCE).isSet()) {
+            audience = context.getProperty(AUDIENCE).evaluateAttributeExpressions().getValue();
+        } else {
+            audience = null;
+        }
+
+        if (context.getProperty(SCOPE).isSet()) {
+            scope = context.getProperty(SCOPE).evaluateAttributeExpressions().getValue();
+        } else {
+            scope = null;
+        }
+
+        if (context.getProperty(JTI).isSet()) {
+            jti = context.getProperty(JTI).evaluateAttributeExpressions().getValue();
+        } else {
+            jti = null;
+        }
+
+        if (context.getProperty(KID).isSet()) {
+            kid = context.getProperty(KID).evaluateAttributeExpressions().getValue();
+        } else {
+            kid = null;
+        }
+
+        if (context.getProperty(SSL_CONTEXT_PROVIDER).isSet()) {
+            SSLContextProvider sslContextProvider = context.getProperty(SSL_CONTEXT_PROVIDER).asControllerService(SSLContextProvider.class);
+            keyManager = sslContextProvider.createKeyManager().orElseThrow(() -> new IllegalStateException("KeyManager not available"));
+        }
+
+        customClaims = new HashMap<>();
+        formParams = new HashMap<>();
+        for (PropertyDescriptor descriptor : context.getProperties().keySet()) {
+            if (descriptor.isDynamic()) {
+                if (descriptor.getName().startsWith(CLAIM_PREFIX)) {
+                    customClaims.put(StringUtils.substringAfter(descriptor.getName(), CLAIM_PREFIX),
+                            context.getProperty(descriptor).evaluateAttributeExpressions().getValue());
+                } else if (descriptor.getName().startsWith(FORM_PREFIX)) {
+                    formParams.put(StringUtils.substringAfter(descriptor.getName(), FORM_PREFIX),
+                            URLEncoder.encode(context.getProperty(descriptor).evaluateAttributeExpressions().getValue(), StandardCharsets.UTF_8));
+                }
+            }
+        }
+    }
+
+    private void initJWTSigner() {
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (privateKey instanceof RSAPrivateKey) {

Review Comment:
   The cast variable name can be used, instead of casting below.
   ```suggestion
           if (privateKey instanceof RSAPrivateKey rsaPrivateKey) {
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2049185711


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);

Review Comment:
   This method is called from from error handling in `requestTokenEndpoint` where we log the HTTP Code and response. I added the URL as well. Not sure we should also add this information in this specific warn log, no?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025214235


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")

Review Comment:
   Same answer: some implementations are very strict in terms of expected fields in the header. I will test again to double check to be sure.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2049310807


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,664 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.oauth2.key.Ed25519Signer;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(
+                    JWSAlgorithm.RS256.getName(),
+                    JWSAlgorithm.RS384.getName(),
+                    JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(),
+                    JWSAlgorithm.PS384.getName(),
+                    JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(),
+                    JWSAlgorithm.ES384.getName(),
+                    JWSAlgorithm.ES512.getName(),
+                    JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header X.509 Cert Thumbprint")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            JTI,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .addMixIn(AccessToken.class, AccessTokenAdditionalParameters.class)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        initProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (!algorithmName.startsWith("RS")
+                    && !algorithmName.startsWith("PS")) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (!algorithm.equals(JWSAlgorithm.ES256)
+                    && !algorithm.equals(JWSAlgorithm.ES384)
+                    && !algorithm.equals(JWSAlgorithm.ES512)) {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (!algorithm.equals(JWSAlgorithm.Ed25519)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        initProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (final Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to acquire Access Token", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+        final Date nowDate = Date.from(now);
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(nowDate)
+                .notBeforeTime(nowDate);
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+        headerBuilder.type(JOSEObjectType.JWT);
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    throw new AccessTokenRetrievalException("Cannot set x5t header because no key alias found");
+                } else {
+                    final PrivateKey privateKey = keyManager.getPrivateKey(alias);
+                    if (privateKey == null) {
+                        throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no private key found for alias %s", alias));
+                    } else {
+                        final X509Certificate[] certificates = keyManager.getCertificateChain(alias);
+                        if (certificates == null || certificates.length == 0) {
+                            throw new AccessTokenRetrievalException(String.format("Cannot set x5t header because no certificate chain found for alias %s", alias));
+                        } else {
+                            final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+                            final byte[] bytes = messageDigest.digest(certificates[0].getEncoded());
+                            String url = Base64.getEncoder().encodeToString(bytes);
+                            headerBuilder.x509CertSHA256Thumbprint(new Base64URL(url));
+                        }
+                    }
+                }
+            } catch (final AccessTokenRetrievalException e) {
+                throw e;
+            } catch (final Exception e) {
+                throw new AccessTokenRetrievalException("Failed to set x5t header", e);
+            }
+        }
+
+        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);
+    }
+
+    protected void requestTokenEndpoint(Map<String, String> formParams) throws URISyntaxException {
+        HttpRequestHeadersSpec request = webClientService
+                .post()
+                .uri(new URI(tokenEndpoint))
+                .header("Accept", APPLICATION_JSON)
+                .header("Content-Type", APPLICATION_URLENCODED)
+                .body(formParams.entrySet()
+                        .stream()
+                        .map(param -> param.getKey() + "=" + param.getValue())
+                        .collect(Collectors.joining("&")));
+
+        try (final HttpResponseEntity response = request.retrieve()) {
+            if (response.statusCode() != 200) {
+                final String message = "Failed to retrieve Access Token: HTTP %s with Response [%s]".formatted(
+                        response.statusCode(),
+                        readBodyAsText(response).orElse("[no body]"));
+
+                throw new AccessTokenRetrievalException(message);
+            }
+
+            try (final InputStream body = response.body()) {
+                accessDetails = ACCESS_DETAILS_MAPPER.readValue(body, AccessToken.class);
+            }
+        } catch (final IOException e) {
+            throw new AccessTokenRetrievalException("Failed to retrieve or process access token details", e);
+        }
+    }
+
+    protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet) throws JOSEException {
+        SignedJWT signedJWT = new SignedJWT(jwsHeader, jwtClaimsSet);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private Optional<String> readBodyAsText(final HttpResponseEntity response) {
+        try (final InputStream is = response.body()) {
+            final String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            return Optional.of(body);
+        } catch (final IOException e) {
+            getLogger().warn("Failed to read response body", e);

Review Comment:
   Thanks, the updated approach looks good.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025211290


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")

Review Comment:
   Unfortunately, the MSGraph example shows a case where Grant Type is different and where the JWT Bearer type is specified via a custom request form.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025262175


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");

Review Comment:
   Given that the property configuration expects the Certificate Thumbprint to be set, I think this should throw an exception as opposed to trying something that seems very unlikely to work.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on PR #9816:
URL: https://github.com/apache/nifi/pull/9816#issuecomment-2748148668

   Thanks for putting this together @pvillard31, I plan to review soon.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2026671486


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java:
##########
@@ -0,0 +1,300 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class JWTBearerOAuth2AccessTokenProviderTest {
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ConfigurationContext mockContext;
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ValidationContext mockValidationContext;
+
+    @Mock
+    private PrivateKeyService mockKeyService;
+
+    @Mock
+    private WebClientServiceProvider mockWebClientServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+
+    @BeforeEach
+    void setUp() {
+        // Initialize mocks
+        MockitoAnnotations.openMocks(this);
+        provider = new JWTBearerOAuth2AccessTokenProviderForTests();
+
+        // Mock PropertyValue for WEB_CLIENT_SERVICE
+        PropertyValue webClientServicePropertyValue = mock(PropertyValue.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE))
+                .thenReturn(webClientServicePropertyValue);
+
+        // Mock WebClientService
+        when(webClientServicePropertyValue.asControllerService(WebClientServiceProvider.class))
+                .thenReturn(mockWebClientServiceProvider);
+
+        when(mockWebClientServiceProvider.getWebClientService()).thenReturn(mockWebClientService);
+    }
+
+    @Test
+    void testClaimsAreSetProperly() throws Exception {
+        // Setup context
+        setContext();
+
+        setPrivateKeyMock(RSAPrivateKey.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM).getValue()).thenReturn(JWSAlgorithm.RS256.getName());

Review Comment:
   See #9841.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "pvillard31 (via GitHub)" <gi...@apache.org>.
pvillard31 commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025222575


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java:
##########
@@ -0,0 +1,677 @@
+/*
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTClaimsSet.Builder;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperties;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.SupportsSensitiveDynamicProperties;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnDisabled;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.expression.AttributeExpression;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.web.client.api.HttpRequestHeadersSpec;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.apache.nifi.web.security.jwt.key.Ed25519Signer;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@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.")
+@DynamicProperties({
+        @DynamicProperty(
+                name = "CLAIM.JWT claim name",
+                value = "JWT claim value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom claims that should be added to the JWT."),
+        @DynamicProperty(
+                name = "FORM.Request parameter name",
+                value = "Request parameter value",
+                expressionLanguageScope = ExpressionLanguageScope.ENVIRONMENT,
+                description = "Custom parameters that should be added to the body of the request against the token endpoint.")
+})
+public class JWTBearerOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider, VerifiableControllerService {
+
+    private static final ObjectMapper ACCESS_DETAILS_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+
+    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)
+            .build();
+
+    public static final PropertyDescriptor SIGNING_ALGORITHM = new PropertyDescriptor.Builder()
+            .name("Signing Algorithm")
+            .description("The algorithm to use for signing the JWT.")
+            .allowableValues(JWSAlgorithm.RS256.getName(), JWSAlgorithm.RS384.getName(), JWSAlgorithm.RS512.getName(),
+                    JWSAlgorithm.PS256.getName(), JWSAlgorithm.PS384.getName(), JWSAlgorithm.PS512.getName(),
+                    JWSAlgorithm.ES256.getName(), JWSAlgorithm.ES384.getName(), JWSAlgorithm.ES512.getName(), JWSAlgorithm.Ed25519.getName())
+            .defaultValue(JWSAlgorithm.PS256.getName())
+            .required(true)
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor ISSUER = new PropertyDescriptor.Builder()
+            .name("Issuer")
+            .description("The issuer claim (iss) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SUBJECT = new PropertyDescriptor.Builder()
+            .name("Subject")
+            .description("The subject claim (sub) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor AUDIENCE = new PropertyDescriptor.Builder()
+            .name("Audience")
+            .description("The audience claim (aud) for the JWT. Space-separated list of audiences if multiple are expected.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor SCOPE = new PropertyDescriptor.Builder()
+            .name("Scope")
+            .description("The scope claim (scope) for the JWT.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor GRANT_TYPE = new PropertyDescriptor.Builder()
+            .name("Grant Type")
+            .description("Value to set for the grant_type parameter in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("urn:ietf:params:oauth:grant-type:jwt-bearer")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor ASSERTION = new PropertyDescriptor.Builder()
+            .name("Assertion Parameter Name")
+            .description("Name of the parameter to use for the JWT assertion in the request to the token endpoint.")
+            .required(true)
+            .defaultValue("assertion")
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor NBF = new PropertyDescriptor.Builder()
+            .name("Set Not Before Time")
+            .description("The nbf (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing."
+                    + " If set to true, the current time will be used.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_TYPE = new PropertyDescriptor.Builder()
+            .name("Set JWT Header Type")
+            .description("If true, the JWT header type (typ) will be set to JWT.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    public static final PropertyDescriptor HEADER_X5T = new PropertyDescriptor.Builder()
+            .name("Set JWT Header x5t")
+            .description("If true, will set the JWT header x5t field with the base64url-encoded SHA-256 thumbprint of the X.509 certificate's DER encoding."
+                    + " If set to true, an instance of SSLContextProvider must be configured with a certificate using RSA algorithm.")
+            .required(true)
+            .allowableValues("true", "false")
+            .defaultValue("false")
+            .addValidator(Validator.VALID)
+            .build();
+
+    static final PropertyDescriptor SSL_CONTEXT_PROVIDER = new PropertyDescriptor.Builder()
+            .name("SSL Context Service")
+            .description("An instance of SSLContextProvider configured with a certificate that will be used to set the x5t header. Must be using RSA algorithm.")
+            .required(true)
+            .dependsOn(HEADER_X5T, "true")
+            .identifiesControllerService(SSLContextProvider.class)
+            .build();
+
+    public static final PropertyDescriptor JTI = new PropertyDescriptor.Builder()
+            .name("JWT ID")
+            .description("The \"jti\" (JWT ID) claim provides a unique identifier for the JWT. The identifier value must be assigned in a "
+                    + "manner that ensures that there's a negligible probability that the same value will be accidentally assigned to a "
+                    + "different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced "
+                    + "by different issuers as well. The \"jti\" value is a case-sensitive string. If set, it is recommended to set this "
+                    + "value to ${UUID()}.")
+            .required(false)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor KID = new PropertyDescriptor.Builder()
+            .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)
+            .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            .build();
+
+    public static final PropertyDescriptor TOKEN_ENDPOINT = new PropertyDescriptor.Builder()
+            .name("Token Endpoint URL")
+            .description("The URL of the OAuth2 token endpoint.")
+            .required(true)
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    public static final PropertyDescriptor WEB_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name("Web Client Service")
+            .description("The Web Client Service to use for calling the token endpoint.")
+            .identifiesControllerService(WebClientServiceProvider.class)
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor REFRESH_WINDOW = new PropertyDescriptor.Builder()
+            .name("Refresh Window")
+            .description("The service will attempt to refresh tokens expiring within the refresh window, subtracting the configured duration from the token expiration.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("5 minutes")
+            .required(true)
+            .build();
+
+    static final PropertyDescriptor JWT_VALIDITY = new PropertyDescriptor.Builder()
+            .name("JWT Expiration Time")
+            .description("Expiration time used to set the corresponding claim of the JWT. In case the returned access token does not include an expiration time, this will"
+                    + " be used with the refresh window to re-acquire a new access token.")
+            .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+            .defaultValue("1 hour")
+            .required(true)
+            .build();
+
+    private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
+            TOKEN_ENDPOINT,
+            WEB_CLIENT_SERVICE,
+            PRIVATE_KEY_SERVICE,
+            SIGNING_ALGORITHM,
+            ISSUER,
+            SUBJECT,
+            AUDIENCE,
+            SCOPE,
+            NBF,
+            JTI,
+            HEADER_TYPE,
+            HEADER_X5T,
+            SSL_CONTEXT_PROVIDER,
+            KID,
+            GRANT_TYPE,
+            ASSERTION,
+            REFRESH_WINDOW,
+            JWT_VALIDITY
+    );
+
+    private volatile AccessToken accessDetails;
+    private volatile JWSSigner signer;
+    private volatile WebClientService webClientService;
+    private volatile PrivateKey privateKey;
+    private volatile X509ExtendedKeyManager keyManager;
+    private volatile String tokenEndpoint;
+    private volatile Duration refreshWindow;
+    private volatile Duration jwtValidity;
+    private volatile String issuer;
+    private volatile String subject;
+    private volatile String audience;
+    private volatile String algorithmName;
+    private volatile String scope;
+    private volatile String jti;
+    private volatile String kid;
+    private volatile String grantType;
+    private volatile String assertion;
+    private volatile boolean nbf;
+    private volatile boolean headerType;
+    private volatile boolean headerX5T;
+
+    private volatile Map<String, String> customClaims;
+    private volatile Map<String, String> formParams;
+
+    static final String CLAIM_PREFIX = "CLAIM.";
+    static final String FORM_PREFIX = "FORM.";
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTY_DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public void onEnabled(ConfigurationContext context) {
+        getProperties(context);
+        initJWTSigner();
+    }
+
+    @OnDisabled
+    public void onDisabled() {
+        accessDetails = null;
+    }
+
+    @Override
+    protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+        if (propertyDescriptorName.startsWith(CLAIM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the claim to add to the JWT.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        if (propertyDescriptorName.startsWith(FORM_PREFIX)) {
+            return new PropertyDescriptor.Builder()
+                    .required(false)
+                    .name(propertyDescriptorName)
+                    .description("The value of the form parameter to add to the request body.")
+                    .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true))
+                    .dynamic(true)
+                    .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+                    .build();
+        }
+
+        return null;
+    }
+
+    @Override
+    protected Collection<ValidationResult> customValidate(ValidationContext validationContext) {
+        final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext));
+
+        PrivateKeyService keyService = validationContext.getProperty(PRIVATE_KEY_SERVICE).asControllerService(PrivateKeyService.class);
+        String algorithmName = validationContext.getProperty(SIGNING_ALGORITHM).getValue();
+        PrivateKey privateKey = keyService.getPrivateKey();
+        JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (validationContext.getProperty(HEADER_X5T).asBoolean() && !(privateKey instanceof RSAPrivateKey)) {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(HEADER_X5T.getDisplayName())
+                    .valid(false)
+                    .explanation("The private key must be an RSA key to set the x5t header.")
+                    .build());
+        }
+
+        if (privateKey instanceof RSAPrivateKey) {
+            if (algorithmName.startsWith("RS") || algorithmName.startsWith("PS")) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of RSA type, the signing algorithm must be either RS256 or PS256.")
+                        .build());
+            }
+        } else if (privateKey instanceof ECPrivateKey) {
+            if (algorithm.equals(JWSAlgorithm.ES256) || algorithm.equals(JWSAlgorithm.ES384) || algorithm.equals(JWSAlgorithm.ES512)) {
+                return validationResults;
+            } else {
+                validationResults.add(new ValidationResult.Builder()
+                        .subject(SIGNING_ALGORITHM.getDisplayName())
+                        .valid(false)
+                        .explanation("The private key is of EC type, the signing algorithm must be either ES256, ES384 or ES512.")
+                        .build());
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            return validationResults;
+        } else {
+            validationResults.add(new ValidationResult.Builder()
+                    .subject(SIGNING_ALGORITHM.getDisplayName())
+                    .valid(false)
+                    .explanation(String.format("The provided key (algorithm = %s) is not supported for signing algorithm %s", privateKey.getAlgorithm(), algorithmName))
+                    .build());
+        }
+
+        return validationResults;
+    }
+
+    @Override
+    public List<ConfigVerificationResult> verify(ConfigurationContext context, ComponentLog verificationLogger, Map<String, String> variables) {
+        getProperties(context);
+        initJWTSigner();
+        ConfigVerificationResult.Builder builder = new ConfigVerificationResult.Builder().verificationStepName("Acquire token");
+        try {
+            getAccessDetails();
+            builder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL);
+        } catch (Exception ex) {
+            String explanation = ex.getMessage();
+            if (ex.getCause() != null) {
+                explanation += " (" + ex.getCause().getMessage() + ")";
+            }
+            builder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+        }
+        return Arrays.asList(builder.build());
+    }
+
+    @Override
+    public AccessToken getAccessDetails() {
+        if (this.accessDetails == null || isRefreshRequired()) {
+            try {
+                acquireAccessDetails();
+            } catch (Exception e) {
+                throw new AccessTokenRetrievalException("Failed to retrieve access token details", e);
+            }
+        }
+        return accessDetails;
+    }
+
+    private boolean isRefreshRequired() {
+        if (accessDetails.getExpiresIn() > 0) {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(accessDetails.getExpiresIn())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        } else {
+            final Instant expirationRefreshTime = accessDetails.getFetchTime()
+                    .plusSeconds(jwtValidity.getSeconds())
+                    .minus(refreshWindow);
+
+            return Instant.now().isAfter(expirationRefreshTime);
+        }
+    }
+
+    private void acquireAccessDetails() throws URISyntaxException, JOSEException {
+        getLogger().debug("New Access Token request started [{}]", tokenEndpoint);
+
+        final Instant now = Instant.now();
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(Date.from(now.plus(jwtValidity)))
+                .issueTime(Date.from(now));
+
+        if (issuer != null) {
+            claimsSetBuilder.issuer(issuer);
+        }
+
+        if (subject != null) {
+            claimsSetBuilder.subject(subject);
+        }
+
+        if (audience != null) {
+            claimsSetBuilder.audience(Arrays.asList(audience.split(" ")));
+        }
+
+        if (scope != null) {
+            claimsSetBuilder.claim("scope", scope);
+        }
+
+        if (nbf) {
+            claimsSetBuilder.notBeforeTime(Date.from(now));
+        }
+
+        if (jti != null) {
+            claimsSetBuilder.jwtID(jti);
+        }
+
+        customClaims.forEach(claimsSetBuilder::claim);
+
+        JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.parse(algorithmName));
+
+        if (headerType) {
+            headerBuilder.type(JOSEObjectType.JWT);
+        }
+
+        if (kid != null) {
+            headerBuilder.keyID(kid);
+        }
+
+        if (headerX5T) {
+            try {
+                final String alias = keyManager.chooseClientAlias(new String[] {"RSA"}, null, null);
+                if (alias == null) {
+                    getLogger().warn("No key alias found, setting x5t header to null.");

Review Comment:
   So I felt like not setting the field, logging a warning to have a bulletin on the controller service, and still trying to acquire the token was the best approach but happy to consider an exception instead if we feel like it's better to not even try.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] NIFI-14380 - OAuth 2.0 Controller Service to support JWT Bearer Flow [nifi]

Posted by "exceptionfactory (via GitHub)" <gi...@apache.org>.
exceptionfactory commented on code in PR #9816:
URL: https://github.com/apache/nifi/pull/9816#discussion_r2025348045


##########
nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java:
##########
@@ -0,0 +1,300 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.key.service.api.PrivateKeyService;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.web.client.api.HttpResponseEntity;
+import org.apache.nifi.web.client.api.WebClientService;
+import org.apache.nifi.web.client.provider.api.WebClientServiceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class JWTBearerOAuth2AccessTokenProviderTest {
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ConfigurationContext mockContext;
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ValidationContext mockValidationContext;
+
+    @Mock
+    private PrivateKeyService mockKeyService;
+
+    @Mock
+    private WebClientServiceProvider mockWebClientServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+
+    @BeforeEach
+    void setUp() {
+        // Initialize mocks
+        MockitoAnnotations.openMocks(this);
+        provider = new JWTBearerOAuth2AccessTokenProviderForTests();
+
+        // Mock PropertyValue for WEB_CLIENT_SERVICE
+        PropertyValue webClientServicePropertyValue = mock(PropertyValue.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE))
+                .thenReturn(webClientServicePropertyValue);
+
+        // Mock WebClientService
+        when(webClientServicePropertyValue.asControllerService(WebClientServiceProvider.class))
+                .thenReturn(mockWebClientServiceProvider);
+
+        when(mockWebClientServiceProvider.getWebClientService()).thenReturn(mockWebClientService);
+    }
+
+    @Test
+    void testClaimsAreSetProperly() throws Exception {
+        // Setup context
+        setContext();
+
+        setPrivateKeyMock(RSAPrivateKey.class);
+        when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM).getValue()).thenReturn(JWSAlgorithm.RS256.getName());

Review Comment:
   The `StandardOauth2AccessTokenProviderTest` uses the `NoOpProcessor` and uses TestRunner to set properties and enable the Controller Service, so I don't think it would be necessary to build a Processor unless I am missing something.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@nifi.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org