This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 838fb691be NIFI-14380 Added OAuth 2.0 Controller Service to support 
JWT Bearer Flow (#9816)
838fb691be is described below

commit 838fb691be6a2f7a4fea97bcf83d9bd9e0cac703
Author: Pierre Villard <[email protected]>
AuthorDate: Tue Apr 22 16:24:23 2025 +0200

    NIFI-14380 Added OAuth 2.0 Controller Service to support JWT Bearer Flow 
(#9816)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../nifi-oauth2-provider-service/pom.xml           |  24 +-
 .../oauth2/JWTBearerOAuth2AccessTokenProvider.java | 668 +++++++++++++++++++++
 .../org/apache/nifi/oauth2/key/Ed25519Signer.java  |  69 +++
 .../org.apache.nifi.controller.ControllerService   |   2 +
 .../additionalDetails.md                           | 104 ++++
 .../JWTBearerOAuth2AccessTokenProviderTest.java    | 329 ++++++++++
 6 files changed, 1195 insertions(+), 1 deletion(-)

diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/pom.xml
 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/pom.xml
index b2bdf70cd6..3956810081 100644
--- 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/pom.xml
+++ 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/pom.xml
@@ -35,14 +35,36 @@
             <groupId>org.apache.nifi</groupId>
             <artifactId>nifi-ssl-context-service-api</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-key-service-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-web-client-provider-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-web-client-api</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.squareup.okhttp3</groupId>
             <artifactId>okhttp</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.nimbusds</groupId>
+            <artifactId>nimbus-jose-jwt</artifactId>
+            <version>10.0.2</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.google.crypto.tink</groupId>
+                    <artifactId>tink</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
         <dependency>
             <groupId>com.squareup.okio</groupId>
             <artifactId>okio-jvm</artifactId>
-            <version>${okio.version}</version>
             <scope>test</scope>
         </dependency>
         <dependency>
diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java
 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProvider.java
new file mode 100644
index 0000000000..b43728e94d
--- /dev/null
+++ 
b/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,668 @@
+/*
+ * 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.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+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.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 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();
+
+    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();
+
+    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);
+        final Date expirationTime = Date.from(now.plus(jwtValidity));
+
+        Builder claimsSetBuilder = new JWTClaimsSet.Builder()
+                .expirationTime(expirationTime)
+                .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 url = getBase64EncodedSHA256Digest();
+                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);
+    }
+
+    private String getBase64EncodedSHA256Digest() throws 
NoSuchAlgorithmException, CertificateEncodingException {
+        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());
+                    return Base64.getEncoder().encodeToString(bytes);
+                }
+            }
+        }
+    }
+
+    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) {
+                String body;
+                try (final InputStream is = response.body()) {
+                    body = new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+                } catch (IOException e) {
+                    body = "[failed to read response: " + e.getMessage() + "]";
+                }
+                final String message = "Failed to retrieve Access Token from 
[%s]: HTTP %s with Response [%s]".formatted(
+                        tokenEndpoint,
+                        response.statusCode(),
+                        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 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() {
+        final JWSAlgorithm algorithm = JWSAlgorithm.parse(algorithmName);
+
+        if (privateKey instanceof RSAPrivateKey rsaPrivateKey) {
+            signer = new RSASSASigner(rsaPrivateKey);
+        } else if (privateKey instanceof ECPrivateKey ecPrivateKey) {
+            try {
+                signer = new ECDSASigner(ecPrivateKey);
+            } catch (final JOSEException e) {
+                throw new IllegalArgumentException("Failed to create ECDSA 
signer", e);
+            }
+        } else if (algorithm.equals(JWSAlgorithm.Ed25519)) {
+            signer = new Ed25519Signer(privateKey);
+        } else {
+            throw new IllegalArgumentException(String.format("The provided key 
(algorithm = %s) is not supported for signing algorithm %s", 
privateKey.getAlgorithm(), algorithmName));
+        }
+    }
+
+    private final class AccessTokenRetrievalException extends RuntimeException 
{
+        public AccessTokenRetrievalException(final String message) {
+            super(message);
+        }
+
+        public AccessTokenRetrievalException(final String message, final 
Throwable cause) {
+            super(message, cause);
+        }
+    }
+
+    interface AccessTokenAdditionalParameters {
+
+        @JsonAnySetter
+        void setAdditionalParameter(String key, Object value);
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/key/Ed25519Signer.java
 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/key/Ed25519Signer.java
new file mode 100644
index 0000000000..5b54865cde
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/java/org/apache/nifi/oauth2/key/Ed25519Signer.java
@@ -0,0 +1,69 @@
+/*
+ * 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.key;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.impl.EdDSAProvider;
+import com.nimbusds.jose.util.Base64URL;
+
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.util.Objects;
+
+/**
+ * Ed25519 implementation of JSON Web Security Signer using Java cryptography
+ */
+public class Ed25519Signer extends EdDSAProvider implements JWSSigner {
+    private static final String SIGNING_ALGORITHM = "Ed25519";
+
+    private final PrivateKey privateKey;
+
+    public Ed25519Signer(final PrivateKey privateKey) {
+        this.privateKey = Objects.requireNonNull(privateKey, "Private Key 
required");
+    }
+
+    /**
+     * Sign bytes for EdDSA algorithm using configured Ed25519 Private Key
+     *
+     * @param jwsHeader JSON Web Security Header
+     * @param bytes Byte array to be signed
+     * @return Base64 encoded signature
+     * @throws JOSEException Thrown on failure produce signature
+     */
+    @Override
+    public Base64URL sign(final JWSHeader jwsHeader, final byte[] bytes) 
throws JOSEException {
+        final JWSAlgorithm algorithm = jwsHeader.getAlgorithm();
+        if (JWSAlgorithm.EdDSA.equals(algorithm)) {
+            try {
+                final Signature signature = 
Signature.getInstance(SIGNING_ALGORITHM);
+                signature.initSign(privateKey);
+                signature.update(bytes);
+
+                final byte[] jwsSignature = signature.sign();
+                return Base64URL.encode(jwsSignature);
+            } catch (final GeneralSecurityException e) {
+                throw new JOSEException("Ed25519 signing failed", e);
+            }
+        } else {
+            throw new JOSEException("JWS Algorithm EdDSA not found 
[%s]".formatted(algorithm));
+        }
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
index cd4bc7ee01..8dc0443dea 100644
--- 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
+++ 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
@@ -12,4 +12,6 @@
 # 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.
+
 org.apache.nifi.oauth2.StandardOauth2AccessTokenProvider
+org.apache.nifi.oauth2.JWTBearerOAuth2AccessTokenProvider
diff --git 
a/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
 
b/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
new file mode 100644
index 0000000000..57464a5956
--- /dev/null
+++ 
b/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,104 @@
+<!--
+  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` |
+| Signing Algorithm        | `RS256` |
+| Issuer                   | The email address of the service account |
+| Subject                  | (optional) The email address of the user for 
which the application is requesting delegated access |
+| Audience                 | `https://oauth2.googleapis.com/token` |
+| Scope                    | A space-delimited list of the permissions that 
the application requests |
+| JWT ID                   | not set |
+| Set JWT Header x5t       | `false` |
+| Key ID                   | The key ID of the service account key |
+| Grant Type               | `urn:ietf:params:oauth:grant-type:jwt-bearer` |
+| Assertion Parameter Name | `assertion` |
+
+### Salesforce 
([source](https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_jwt_flow.htm&type=5))
+
+| Property name/key        | Property value |
+| ------------------------ | -------------- |
+| Token Endpoint           | 
`https://my-instance.develop.my.salesforce.com/services/oauth2/token` |
+| Signing Algorithm        | `RS256` |
+| Issuer                   | The issuer must contain the OAuth `client_id` 
(Consumer Key) of the connected app for which you registered the certificate |
+| Subject                  | (optional) If you’re implementing this flow for 
an Experience Cloud site, the subject must contain the user’s username |
+| Audience                 | The audience identifies the authorization server 
as an intended audience. It can be `https://login.salesforce.com` or 
`https://test.salesforce.com` for sandboxes |
+| Scope                    | not set |
+| JWT ID                   | not set |
+| Set JWT Header x5t       | `false` |
+| Key ID                   | not set |
+| Grant Type               | `urn:ietf:params:oauth:grant-type:jwt-bearer` |
+| Assertion Parameter Name | `assertion` |
+
+### Microsoft 
([source](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate))
+
+| Property name/key        | Property value |
+| ------------------------ | -------------- |
+| Token Endpoint           | 
`https://login.microsoftonline.com/<tenantId>/oauth2/v2.0/token` |
+| Signing Algorithm        | `PS256` |
+| Issuer                   | Use the GUID application ID |
+| Subject                  | Use the same value as issuer |
+| Audience                 | 
`https://login.microsoftonline.com/<tenantId>/oauth2/v2.0/token` |
+| Scope                    | not set |
+| JWT ID                   | `${UUID()}` |
+| Set JWT Header x5t       | `true` |
+| SSL Context Service      | SSL Context Service to provide the public 
certificate in order to have its thumbprint in the JWT header |
+| Key ID                   | not set |
+| Grant Type               | `client_credentials` |
+| Assertion Parameter Name | `client_assertion` |
+| `FORM.client_id`           | The application ID that's assigned to your app |
+| `FORM.tenant`              | The directory tenant the application plans to 
operate against, in GUID or domain-name format |
+| `FORM.scope`               | The value passed for the scope parameter in 
this request should be the resource identifier (application ID URI) of the 
resource you want, suffixed with `.default`. All scopes included must be for a 
single resource. Including scopes for multiple resources will result in an 
error. For the Microsoft Graph example, the value is 
`https://graph.microsoft.com/.default` |
+| `FORM.client_assertion_type` | 
`urn:ietf:params:oauth:client-assertion-type:jwt-bearer` |
+
+### Box 
([source](https://developer.box.com/guides/authentication/jwt/without-sdk/#3-create-jwt-assertion))
+
+| Property name/key        | Property value |
+| ------------------------ | -------------- |
+| Token Endpoint           | `https://api.box.com/oauth2/token` |
+| Signing Algorithm        | `RS256`, `RS384`, or `RS512` |
+| Issuer                   | The Box Application's OAuth client ID |
+| Subject                  | The Box Enterprise ID if this app is to act on 
behalf of the Service Account of that application, or the User ID if this app 
wants to act on behalf of another user. |
+| Audience                 | `https://api.box.com/oauth2/token` |
+| Scope                    | not set |
+| JWT ID                   | `${UUID()}` |
+| Set JWT Header x5t       | `false` |
+| Key ID                   | The ID of the public key used to sign the JWT. 
Not required, though essential when multiple key pairs are defined for an 
application. |
+| Grant Type               | `urn:ietf:params:oauth:grant-type:jwt-bearer` |
+| Assertion Parameter Name | `assertion` |
+| JWT Expiration Time      | `1 minute` (cannot be more than 60 seconds) |
+| `CLAIM.box_sub_type`       | `enterprise` or `user` depending on the type of 
token being requested in the `sub` claim |
+| `FORM.client_id`           | Client ID |
+| `FORM.client_secret`       | Client Secret |
diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java
 
b/nifi-extension-bundles/nifi-standard-services/nifi-oauth2-provider-bundle/nifi-oauth2-provider-service/src/test/java/org/apache/nifi/oauth2/JWTBearerOAuth2AccessTokenProviderTest.java
new file mode 100644
index 0000000000..0a0317b441
--- /dev/null
+++ 
b/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,329 @@
+/*
+ * 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.ConfigVerificationResult;
+import org.apache.nifi.components.ConfigVerificationResult.Outcome;
+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.processor.Processor;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.ssl.SSLContextProvider;
+import org.apache.nifi.util.NoOpProcessor;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+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.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+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 SSLContextProvider sslContextServiceProvider;
+
+    @Mock
+    private WebClientService mockWebClientService;
+
+    @Mock
+    private X509ExtendedKeyManager keyManager;
+
+    @Mock
+    private HttpResponseEntity mockResponseEntity;
+
+    @Mock
+    private ComponentLog mockLogger;
+
+    private JWTBearerOAuth2AccessTokenProviderForTests provider;
+    private Processor processor = new NoOpProcessor();
+    private TestRunner runner;
+
+    @BeforeEach
+    void setUp() throws InitializationException {
+        runner = TestRunners.newTestRunner(processor);
+        provider = new JWTBearerOAuth2AccessTokenProviderForTests();
+        runner.addControllerService("oauthTokenProvider", provider);
+
+        // Mock PropertyValue for WEB_CLIENT_SERVICE
+        PropertyValue webClientServicePropertyValue = 
mock(PropertyValue.class);
+        
when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE))
+                .thenReturn(webClientServicePropertyValue);
+
+        // Mock WebClientService
+        
lenient().when(webClientServicePropertyValue.asControllerService(WebClientServiceProvider.class))
+                .thenReturn(mockWebClientServiceProvider);
+
+        
lenient().when(mockWebClientServiceProvider.getWebClientService()).thenReturn(mockWebClientService);
+        
lenient().when(mockWebClientServiceProvider.getIdentifier()).thenReturn("webClientService");
+
+        runner.addControllerService("webClientService", 
mockWebClientServiceProvider);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.WEB_CLIENT_SERVICE, "webClientService");
+        runner.enableControllerService(mockWebClientServiceProvider);
+
+        setContext();
+    }
+
+    @Test
+    void testClaimsAreSetProperly() throws Exception {
+        setPrivateKeyMock(RSAPrivateKey.class);
+
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.RS256.getName());
+        runner.setProperty(provider, JWTBearerOAuth2AccessTokenProvider.SCOPE, 
"TestScope");
+        runner.setProperty(provider, JWTBearerOAuth2AccessTokenProvider.JTI, 
"TestJTI");
+
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.CLAIM_PREFIX + "customClaim1", 
"customClaimValue1");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.CLAIM_PREFIX + "customClaim2", 
"customClaimValue2");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.FORM_PREFIX + "clientId", "clientId");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.FORM_PREFIX + "clientSecret", 
"clientSecret");
+
+        runner.enableControllerService(provider);
+
+        // Generate the JWT using the verify method to confirm it works
+        final List<ConfigVerificationResult> configVerifResults = 
runner.verify(provider, Map.of());
+
+        // all configuration results should be successful
+        assertTrue(configVerifResults.size() > 0);
+        assertEquals(0, configVerifResults.stream().filter(result -> 
!result.getOutcome().equals(Outcome.SUCCESSFUL)).count());
+
+        // Validate the claims are properly set
+        JWTClaimsSet claimsSet = provider.getJwtClaimsSet();
+        assertEquals("TestIssuer", claimsSet.getIssuer());
+        assertEquals("TestSubject", claimsSet.getSubject());
+        assertEquals("TestAudience1", claimsSet.getAudience().get(0));
+        assertEquals("TestAudience2", claimsSet.getAudience().get(1));
+        assertEquals("TestScope", claimsSet.getStringClaim("scope"));
+        assertEquals("TestJTI", claimsSet.getJWTID());
+        assertNotNull(claimsSet.getNotBeforeTime());
+
+        // validate custom claims
+        assertEquals("customClaimValue1", 
claimsSet.getStringClaim("customClaim1"));
+        assertEquals("customClaimValue2", 
claimsSet.getStringClaim("customClaim2"));
+
+        // validate the header
+        JWSHeader jwsHeader = provider.getJwsHeader();
+        assertEquals(JWSAlgorithm.RS256, jwsHeader.getAlgorithm());
+        assertEquals("{\"typ\":\"JWT\",\"alg\":\"RS256\"}", 
jwsHeader.toString());
+
+        // validate the form parameters
+        Map<String, String> formParams = provider.getFormParams();
+        assertEquals("test-assertion", formParams.get("customAssertionField"));
+        assertEquals("clientId", formParams.get("clientId"));
+        assertEquals("clientSecret", formParams.get("clientSecret"));
+        assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer", 
formParams.get("grant_type"));
+        assertEquals(4, formParams.size());
+    }
+
+    @Test
+    void testES512vsRSA() throws Exception {
+        setPrivateKeyMock(RSAPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.ES512.getName());
+        final Collection<ValidationResult> validations = 
runner.validate(provider);
+        assertEquals(1, validations.size());
+        assertTrue(validations.stream().anyMatch(validation -> 
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM.getDisplayName())));
+    }
+
+    @Test
+    void testRS512vsRSA() throws Exception {
+        setPrivateKeyMock(RSAPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.RS512.getName());
+        runner.assertValid(provider);
+    }
+
+    @Test
+    void testRS512vsRSAwithoutX5T() throws Exception {
+        setPrivateKeyMock(RSAPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.RS512.getName());
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.HEADER_X5T, Boolean.FALSE.toString());
+        runner.assertValid(provider);
+    }
+
+    @Test
+    void testRS512vsRSAwithX5TNoSSL() throws Exception {
+        setPrivateKeyMock(RSAPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.RS512.getName());
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.HEADER_X5T, Boolean.TRUE.toString());
+        runner.assertNotValid(provider);
+        final Collection<ValidationResult> validations = 
runner.validate(provider);
+        assertEquals(1, validations.size());
+        // SSL context provider should be present since x5t property is set to 
true
+        assertTrue(validations.stream().anyMatch(validation -> 
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.SSL_CONTEXT_PROVIDER.getDisplayName())));
+    }
+
+    @Test
+    void testRS512vsEC() throws Exception {
+        setPrivateKeyMock(ECPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.RS512.getName());
+        final Collection<ValidationResult> validations = 
runner.validate(provider);
+        assertEquals(1, validations.size());
+        assertTrue(validations.stream().anyMatch(validation -> 
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM.getDisplayName())));
+    }
+
+    @Test
+    void testX5TvsEC() throws Exception {
+        setPrivateKeyMock(ECPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.RS512.getName());
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.HEADER_X5T, Boolean.TRUE.toString());
+
+        PropertyValue sslServicePropertyValue = mock(PropertyValue.class);
+        
when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.SSL_CONTEXT_PROVIDER))
+                .thenReturn(sslServicePropertyValue);
+
+        
lenient().when(sslServicePropertyValue.asControllerService(SSLContextProvider.class))
+                .thenReturn(sslContextServiceProvider);
+
+        
lenient().when(sslContextServiceProvider.createKeyManager()).thenReturn(Optional.of(keyManager));
+        
lenient().when(sslContextServiceProvider.getIdentifier()).thenReturn("sslService");
+
+        runner.addControllerService("sslService", sslContextServiceProvider);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SSL_CONTEXT_PROVIDER, "sslService");
+        runner.enableControllerService(sslContextServiceProvider);
+
+        final Collection<ValidationResult> validations = 
runner.validate(provider);
+        assertEquals(2, validations.size());
+        assertTrue(validations.stream().anyMatch(validation -> 
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM.getDisplayName())));
+        assertTrue(validations.stream().anyMatch(validation -> 
validation.getSubject().equals(JWTBearerOAuth2AccessTokenProvider.HEADER_X5T.getDisplayName())));
+    }
+
+    @Test
+    void testES512vsEC() throws Exception {
+        setPrivateKeyMock(ECPrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.ES512.getName());
+        runner.assertValid(provider);
+    }
+
+    @Test
+    void testEdvsOctet() throws Exception {
+        setPrivateKeyMock(PrivateKey.class);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SIGNING_ALGORITHM, 
JWSAlgorithm.Ed25519.getName());
+        runner.assertValid(provider);
+    }
+
+    private void setContext() {
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.ISSUER, "TestIssuer");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.SUBJECT, "TestSubject");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.AUDIENCE, "TestAudience1 TestAudience2");
+        runner.setProperty(provider, JWTBearerOAuth2AccessTokenProvider.JTI, 
"TestJTI");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.TOKEN_ENDPOINT, "http://example.com/token";);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.GRANT_TYPE, 
"urn:ietf:params:oauth:grant-type:jwt-bearer");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.ASSERTION, "customAssertionField");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.JWT_VALIDITY, "1 hour");
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.REFRESH_WINDOW, "5 minutes");
+    }
+
+    private void setPrivateKeyMock(Class<? extends PrivateKey> pkClass) throws 
InitializationException {
+        // Mock the PrivateKeyService directly
+        PrivateKey mockPrivateKey = mock(pkClass);
+        PrivateKeyService keyService = mock(PrivateKeyService.class);
+        lenient().when(keyService.getPrivateKey()).thenReturn(mockPrivateKey);
+
+        // Mock PropertyValue for PRIVATE_KEY_SERVICE
+        PropertyValue privateKeyServicePropertyValue = 
mock(PropertyValue.class);
+        
lenient().when(mockContext.getProperty(JWTBearerOAuth2AccessTokenProvider.PRIVATE_KEY_SERVICE))
+                .thenReturn(privateKeyServicePropertyValue);
+        
lenient().when(mockValidationContext.getProperty(JWTBearerOAuth2AccessTokenProvider.PRIVATE_KEY_SERVICE))
+                .thenReturn(privateKeyServicePropertyValue);
+
+        // When asControllerService() is called on the PropertyDescriptor, 
return the
+        // mock PrivateKeyService
+        
lenient().when(privateKeyServicePropertyValue.asControllerService(PrivateKeyService.class)).thenReturn(keyService);
+        lenient().when(keyService.getIdentifier()).thenReturn("keyService");
+
+        runner.addControllerService("keyService", keyService);
+        runner.setProperty(provider, 
JWTBearerOAuth2AccessTokenProvider.PRIVATE_KEY_SERVICE, "keyService");
+        runner.enableControllerService(keyService);
+    }
+
+    private class JWTBearerOAuth2AccessTokenProviderForTests extends 
JWTBearerOAuth2AccessTokenProvider {
+        private JWSHeader jwsHeader;
+        private JWTClaimsSet jwtClaimsSet;
+        private Map<String, String> formParams;
+
+        @Override
+        protected ComponentLog getLogger() {
+            return mockLogger;
+        }
+
+        @Override
+        protected String getAssertion(JWSHeader jwsHeader, JWTClaimsSet 
jwtClaimsSet) throws JOSEException {
+            this.jwsHeader = jwsHeader;
+            this.jwtClaimsSet = jwtClaimsSet;
+            return "test-assertion";
+        }
+
+        @Override
+        protected void requestTokenEndpoint(Map<String, String> formParams) 
throws URISyntaxException {
+            this.formParams = formParams;
+        }
+
+        public JWSHeader getJwsHeader() {
+            return jwsHeader;
+        }
+
+        public JWTClaimsSet getJwtClaimsSet() {
+            return jwtClaimsSet;
+        }
+
+        public Map<String, String> getFormParams() {
+            return formParams;
+        }
+    }
+
+}

Reply via email to