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 df1f9cd2573 NIFI-16367 Add Azure Entra Database Password Provider
(#11693)
df1f9cd2573 is described below
commit df1f9cd25737d02ebe0e667d0aca127afc742257
Author: Pierre Villard <[email protected]>
AuthorDate: Fri Sep 18 22:04:25 2026 +0200
NIFI-16367 Add Azure Entra Database Password Provider (#11693)
Signed-off-by: David Handermann <[email protected]>
---
.../nifi-azure-processors/pom.xml | 6 +
.../azure/AzureEntraDatabasePasswordProvider.java | 215 +++++++
.../org.apache.nifi.controller.ControllerService | 1 +
.../additionalDetails.md | 92 +++
.../AzureEntraDatabasePasswordProviderTest.java | 616 +++++++++++++++++++++
5 files changed, 930 insertions(+)
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/pom.xml
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/pom.xml
index 724dda1c360..c4145b66d79 100644
--- a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/pom.xml
+++ b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/pom.xml
@@ -32,6 +32,12 @@
<artifactId>nifi-oauth2-provider-api</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.nifi</groupId>
+ <artifactId>nifi-dbcp-service-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-service-utils</artifactId>
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/services/azure/AzureEntraDatabasePasswordProvider.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/services/azure/AzureEntraDatabasePasswordProvider.java
new file mode 100644
index 00000000000..53c4acf3e1e
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/java/org/apache/nifi/services/azure/AzureEntraDatabasePasswordProvider.java
@@ -0,0 +1,215 @@
+/*
+ * 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.services.azure;
+
+import com.azure.core.credential.AccessToken;
+import com.azure.core.credential.TokenCredential;
+import com.azure.core.credential.TokenRequestContext;
+import org.apache.commons.lang3.StringUtils;
+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.ConfigVerificationResult.Outcome;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.controller.VerifiableControllerService;
+import org.apache.nifi.dbcp.api.DatabasePasswordProvider;
+import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.exception.ProcessException;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Tags({"azure", "microsoft entra", "jdbc", "database password", "postgresql",
"mysql", "authentication"})
+@CapabilityDescription("""
+ Acquires short-lived Microsoft Entra database passwords for JDBC
authentication to Azure Database for PostgreSQL Flexible Server and
+ Azure Database for MySQL Flexible Server. Only Azure Database for
PostgreSQL Flexible Server and Azure Database for MySQL Flexible
+ Server are supported.
+ """)
+public class AzureEntraDatabasePasswordProvider extends
AbstractControllerService
+ implements DatabasePasswordProvider, VerifiableControllerService {
+
+ static final String OSS_RDBMS_SCOPE =
"https://ossrdbms-aad.database.windows.net/.default";
+ static final String FAILED_CREDENTIAL_RESOLUTION_MESSAGE = "Failed to
resolve Azure credentials for Microsoft Entra database password.";
+ static final String FAILED_TOKEN_ACQUISITION_MESSAGE = "Failed to acquire
a valid Microsoft Entra database access token.";
+ static final String VERIFY_CREDENTIALS_STEP = "Resolve Azure credentials";
+ static final String VERIFY_TOKEN_STEP = "Acquire Microsoft Entra database
access token";
+ static final String VERIFY_CREDENTIALS_UNAVAILABLE = "Configured Azure
Credentials Service did not return Azure credentials.";
+ static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a
valid Microsoft Entra database access token.";
+ static final Duration DEFAULT_TOKEN_ACQUISITION_TIMEOUT =
Duration.ofMinutes(2);
+
+ static final PropertyDescriptor AZURE_CREDENTIALS_SERVICE = new
PropertyDescriptor.Builder()
+ .name("Azure Credentials Service")
+ .description("Controller Service that provides the Azure
credentials used to request Microsoft Entra database access tokens.")
+ .identifiesControllerService(AzureCredentialsService.class)
+ .required(true)
+ .build();
+
+ private static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS =
List.of(
+ AZURE_CREDENTIALS_SERVICE
+ );
+
+ private final Duration tokenAcquisitionTimeout;
+ private volatile AzureCredentialsService azureCredentialsService;
+
+ public AzureEntraDatabasePasswordProvider() {
+ this(DEFAULT_TOKEN_ACQUISITION_TIMEOUT);
+ }
+
+ AzureEntraDatabasePasswordProvider(final Duration tokenAcquisitionTimeout)
{
+ this.tokenAcquisitionTimeout =
requirePositiveDuration(tokenAcquisitionTimeout);
+ }
+
+ @Override
+ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @OnEnabled
+ public void onEnabled(final ConfigurationContext context) {
+ azureCredentialsService = resolveAzureCredentialsService(context);
+ }
+
+ @OnDisabled
+ public void onDisabled() {
+ azureCredentialsService = null;
+ }
+
+ @Override
+ public char[] getPassword(final DatabasePasswordRequestContext
requestContext) {
+ Objects.requireNonNull(requestContext, "Database Password Request
Context required");
+
+ final AzureCredentialsService configuredCredentialsService =
azureCredentialsService;
+ if (configuredCredentialsService == null) {
+ throw new ProcessException(FAILED_CREDENTIAL_RESOLUTION_MESSAGE);
+ }
+
+ final TokenCredential credential;
+ try {
+ credential = configuredCredentialsService.getCredentials();
+ } catch (final RuntimeException e) {
+ throw new ProcessException(FAILED_CREDENTIAL_RESOLUTION_MESSAGE);
+ }
+
+ if (credential == null) {
+ throw new ProcessException(FAILED_CREDENTIAL_RESOLUTION_MESSAGE);
+ }
+
+ final AccessToken accessToken;
+ try {
+ accessToken =
credential.getToken(createTokenRequestContext()).block(tokenAcquisitionTimeout);
+ } catch (final RuntimeException e) {
+ throw new ProcessException(FAILED_TOKEN_ACQUISITION_MESSAGE);
+ }
+
+ if (!isValidAccessToken(accessToken)) {
+ throw new ProcessException(FAILED_TOKEN_ACQUISITION_MESSAGE);
+ }
+
+ return accessToken.getToken().toCharArray();
+ }
+
+ @Override
+ public List<ConfigVerificationResult> verify(final ConfigurationContext
context, final ComponentLog verificationLogger,
+ final Map<String, String>
attributes) {
+ final List<ConfigVerificationResult> results = new ArrayList<>(2);
+
+ final TokenCredential verificationCredential =
resolveVerificationCredential(context, verificationLogger);
+ if (verificationCredential == null) {
+ results.add(buildVerificationResult(VERIFY_CREDENTIALS_STEP,
Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE));
+ return results;
+ }
+
+ results.add(buildVerificationResult(
+ VERIFY_CREDENTIALS_STEP,
+ Outcome.SUCCESSFUL,
+ "Resolved Azure credentials service and current
TokenCredential."
+ ));
+ results.add(verifyAccessToken(verificationCredential,
verificationLogger));
+ return results;
+ }
+
+ private TokenCredential resolveVerificationCredential(final
ConfigurationContext context, final ComponentLog verificationLogger) {
+ final AzureCredentialsService verificationCredentialsService =
resolveAzureCredentialsService(context);
+ if (verificationCredentialsService == null) {
+ return null;
+ }
+
+ try {
+ return verificationCredentialsService.getCredentials();
+ } catch (final RuntimeException e) {
+ verificationLogger.error(VERIFY_CREDENTIALS_UNAVAILABLE);
+ return null;
+ }
+ }
+
+ private ConfigVerificationResult verifyAccessToken(final TokenCredential
credential, final ComponentLog verificationLogger) {
+ final AccessToken accessToken;
+ try {
+ accessToken =
credential.getToken(createTokenRequestContext()).block(tokenAcquisitionTimeout);
+ } catch (final RuntimeException e) {
+ verificationLogger.error(VERIFY_TOKEN_ACQUISITION_FAILED);
+ return buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED,
VERIFY_TOKEN_ACQUISITION_FAILED);
+ }
+
+ if (!isValidAccessToken(accessToken)) {
+ verificationLogger.error(VERIFY_TOKEN_ACQUISITION_FAILED);
+ return buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED,
VERIFY_TOKEN_ACQUISITION_FAILED);
+ }
+
+ return buildVerificationResult(
+ VERIFY_TOKEN_STEP,
+ Outcome.SUCCESSFUL,
+ "Acquired a Microsoft Entra database access token. Use DBCP
Verify to validate database connectivity."
+ );
+ }
+
+ private AzureCredentialsService resolveAzureCredentialsService(final
ConfigurationContext context) {
+ return
context.getProperty(AZURE_CREDENTIALS_SERVICE).asControllerService(AzureCredentialsService.class);
+ }
+
+ private TokenRequestContext createTokenRequestContext() {
+ return new TokenRequestContext().addScopes(OSS_RDBMS_SCOPE);
+ }
+
+ private Duration requirePositiveDuration(final Duration
tokenAcquisitionTimeout) {
+ final Duration configuredTimeout =
Objects.requireNonNull(tokenAcquisitionTimeout, "Token acquisition timeout
required");
+ if (configuredTimeout.isZero() || configuredTimeout.isNegative()) {
+ throw new IllegalArgumentException("Token acquisition timeout must
be positive");
+ }
+ return configuredTimeout;
+ }
+
+ private boolean isValidAccessToken(final AccessToken accessToken) {
+ return accessToken != null &&
StringUtils.isNotBlank(accessToken.getToken());
+ }
+
+ private ConfigVerificationResult buildVerificationResult(final String
stepName, final Outcome outcome, final String explanation) {
+ return new ConfigVerificationResult.Builder()
+ .verificationStepName(stepName)
+ .outcome(outcome)
+ .explanation(explanation)
+ .build();
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
index e76b973a781..f1483760db6 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
@@ -22,5 +22,6 @@
org.apache.nifi.services.azure.storage.AzureStorageCredentialsControllerService_
org.apache.nifi.services.azure.storage.AzureStorageCredentialsControllerServiceLookup_v12
org.apache.nifi.services.azure.StandardAzureCredentialsControllerService
org.apache.nifi.services.azure.StandardAzureIdentityFederationTokenProvider
+org.apache.nifi.services.azure.AzureEntraDatabasePasswordProvider
org.apache.nifi.services.azure.storage.AzureBlobStorageFileResourceService
org.apache.nifi.services.azure.storage.AzureDataLakeStorageFileResourceService
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/docs/org.apache.nifi.services.azure.AzureEntraDatabasePasswordProvider/additionalDetails.md
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/docs/org.apache.nifi.services.azure.AzureEntraDatabasePasswordProvider/additionalDetails.md
new file mode 100644
index 00000000000..84e3988f5e2
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/main/resources/docs/org.apache.nifi.services.azure.AzureEntraDatabasePasswordProvider/additionalDetails.md
@@ -0,0 +1,92 @@
+<!--
+ 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.
+-->
+
+## Summary
+
+`AzureEntraDatabasePasswordProvider` acquires a short-lived Microsoft Entra
access token and supplies it as the
+database password for a DBCP service. Use it to connect NiFi to Azure Database
for PostgreSQL Flexible Server or Azure
+Database for MySQL Flexible Server without storing a long-lived database
password in NiFi. Other database products and
+sovereign Azure clouds are not supported.
+
+The provider supplies a password when DBCP creates a new physical connection.
Existing pooled connections are not
+reauthenticated when the token expires.
+
+## Usage
+
+1. Configure an Azure Credentials Service that can obtain credentials for the
public Azure cloud.
+2. Create and enable `AzureEntraDatabasePasswordProvider`.
+3. Set **Azure Credentials Service** to the configured credentials service.
+4. Configure the DBCP service with the JDBC URL, driver, database user, and
**Database Password Provider** set to
+ `AzureEntraDatabasePasswordProvider`.
+5. Run **Verify** on the provider, then run **Verify** on the DBCP service.
+
+Create the Microsoft Entra principal in the database separately and grant the
database privileges required by NiFi.
+
+## Workload Identity Federation
+
+For Workload Identity Federation, configure
`StandardAzureCredentialsControllerService` with **Credentials Strategy**
+set to **Identity Federation** and select a
`StandardAzureIdentityFederationTokenProvider`. The federation token
+provider accepts an OAuth2 access token provider that supplies the external
client assertion. The federated identity
+credential in Microsoft Entra must match the assertion issuer, subject, and
audience.
+
+The password provider requests the public-cloud Azure OSS RDBMS resource. It
does not require the Azure JDBC
+authentication plugins and does not depend on a particular external assertion
issuer.
+
+## PostgreSQL Configuration
+
+Configure Microsoft Entra authentication on Azure Database for PostgreSQL
Flexible Server and create the database
+role that corresponds to the Entra principal. Set the DBCP **Database User**
to that mapped role name.
+
+The token is sent using PostgreSQL cleartext-password authentication and must
be protected by TLS. Use direct port
+5432 as the baseline and set `sslmode=verify-full`, with the client configured
to trust the server certificate chain.
+
+| Setting | Value |
+|---|---|
+| Driver Class Name | `org.postgresql.Driver` |
+| JDBC URL |
`jdbc:postgresql://<SERVER>.postgres.database.azure.com:5432/<DATABASE>?sslmode=verify-full`
|
+| Database User | Microsoft Entra principal's mapped PostgreSQL role name |
+
+Azure's built-in PgBouncer on port 6432 must be validated separately. Large
Entra tokens containing many group claims
+can exceed intermediary protocol buffers even when the same token works
against direct port 5432. This provider does
+not claim unrestricted PgBouncer compatibility.
+
+## MySQL Configuration
+
+Configure Microsoft Entra authentication on Azure Database for MySQL Flexible
Server and create an Entra user or alias
+for the principal. Set the DBCP **Database User** to the mapped alias. MySQL
user names are limited to 32 characters, so
+an alias can be required for a longer Entra principal name.
+
+MySQL Connector/J must use TLS, verify the server certificate, and permit the
`mysql_clear_password` authentication
+mechanism used to transmit the token. Configure Connector/J with a trust store
containing the issuing CA certificate,
+use `sslMode=VERIFY_IDENTITY`, and set `allowCleartextPasswords=true`. The
Azure MySQL JDBC authentication plugin is not
+required when this provider supplies the token.
+
+| Setting | Value |
+|---|---|
+| Driver Class Name | `com.mysql.cj.jdbc.Driver` |
+| Driver Location(s) | compatible MySQL Connector/J driver jar provided to the
DBCP service |
+| JDBC URL |
`jdbc:mysql://<SERVER>.mysql.database.azure.com:3306/<DATABASE>?sslMode=VERIFY_IDENTITY&allowCleartextPasswords=true`
|
+| Database User | Microsoft Entra principal's mapped MySQL user or alias |
+
+## Verify and Troubleshooting
+
+`AzureEntraDatabasePasswordProvider` **Verify** checks that the configured
Azure Credentials Service can acquire a
+nonblank token for the Azure OSS RDBMS resource. It does not validate the JDBC
URL, database server, network
+path, TLS configuration, driver, principal mapping, or database grants. DBCP
**Verify** checks the actual database
+connection using those settings.
+
+If provider **Verify** fails, confirm that the Azure Credentials Service and
any upstream identity-federation services
+are enabled and configured for the intended Entra application and tenant. If
provider **Verify** succeeds but DBCP
+**Verify** fails, investigate the JDBC connection, network, TLS, driver,
database principal mapping, and grants.
\ No newline at end of file
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/services/azure/AzureEntraDatabasePasswordProviderTest.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/services/azure/AzureEntraDatabasePasswordProviderTest.java
new file mode 100644
index 00000000000..aa7861f75aa
--- /dev/null
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-processors/src/test/java/org/apache/nifi/services/azure/AzureEntraDatabasePasswordProviderTest.java
@@ -0,0 +1,616 @@
+/*
+ * 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.services.azure;
+
+import com.azure.core.credential.AccessToken;
+import com.azure.core.credential.TokenCredential;
+import com.azure.core.credential.TokenRequestContext;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.dbcp.api.DatabasePasswordProvider;
+import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.util.LogMessage;
+import org.apache.nifi.util.MockComponentLog;
+import org.apache.nifi.util.NoOpProcessor;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.slf4j.helpers.MessageFormatter;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import static
org.apache.nifi.components.ConfigVerificationResult.Outcome.FAILED;
+import static
org.apache.nifi.components.ConfigVerificationResult.Outcome.SUCCESSFUL;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AzureEntraDatabasePasswordProviderTest {
+
+ private static final String CREDENTIALS_SERVICE_ID = "azureCredentials";
+ private static final String PASSWORD_PROVIDER_ID =
"azureEntraPasswordProvider";
+ private static final String DRIVER_CLASS = "org.postgresql.Driver";
+ private static final String DATABASE_USER = "[email protected]";
+ private static final String JDBC_URL =
"jdbc:postgresql://example.postgres.database.azure.com:5432/database";
+ private static final String TOKEN_VALUE = "entra-database-token";
+ private static final String REFRESHED_TOKEN_VALUE =
"refreshed-entra-database-token";
+ private static final String LEAK_SENTINEL = "sentinel-entra-secret-value";
+ private static final Duration SHORT_TOKEN_ACQUISITION_TIMEOUT =
Duration.ofMillis(50);
+
+ private ExecutorService executorService;
+
+ @AfterEach
+ void tearDown() {
+ if (executorService != null) {
+ executorService.shutdownNow();
+ }
+ }
+
+ @Test
+ void
testSupportedPropertyDescriptorsContainOnlyCredentialsServiceAndCredentialsPropertyIsRequired()
throws Exception {
+ final AzureEntraDatabasePasswordProvider provider = new
AzureEntraDatabasePasswordProvider();
+ final List<PropertyDescriptor> descriptors =
provider.getSupportedPropertyDescriptors();
+
+ assertEquals(1, descriptors.size());
+
assertEquals(AzureEntraDatabasePasswordProvider.AZURE_CREDENTIALS_SERVICE,
descriptors.get(0));
+ assertTrue(descriptors.get(0).isRequired());
+ final TestRunner runner =
TestRunners.newTestRunner(NoOpProcessor.class);
+
+ runner.addControllerService(PASSWORD_PROVIDER_ID, provider);
+ runner.assertNotValid(provider);
+ }
+
+ @Test
+ void testGetPasswordUsesCurrentCredentialForEveryCall() throws Exception {
+ final TestAzureCredentialsService credentialsService = new
TestAzureCredentialsService(
+ new StaticTokenCredential(validToken(TOKEN_VALUE)),
+ new StaticTokenCredential(validToken(REFRESHED_TOKEN_VALUE))
+ );
+ final DatabasePasswordProvider provider =
getProvider(configureRunner(credentialsService));
+
+ final char[] firstPassword = provider.getPassword(requestContext());
+ final char[] firstPasswordCopy = firstPassword.clone();
+ firstPassword[0] = 'X';
+ final char[] secondPassword = provider.getPassword(requestContext());
+
+ assertEquals(2, credentialsService.getGetCredentialsCount());
+ assertNotSame(firstPassword, secondPassword);
+ assertArrayEquals(TOKEN_VALUE.toCharArray(), firstPasswordCopy);
+ assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), secondPassword);
+ }
+
+ @Test
+ void testGetPasswordRequestsExactOssRdbmsScope() throws Exception {
+ final RecordingTokenCredential credential = new
RecordingTokenCredential(Mono.just(validToken(TOKEN_VALUE)));
+ final DatabasePasswordProvider provider =
getProvider(configureRunner(new TestAzureCredentialsService(credential)));
+
+ provider.getPassword(requestContext());
+
+
assertEquals(List.of(AzureEntraDatabasePasswordProvider.OSS_RDBMS_SCOPE),
credential.getLastRequestedScopes());
+ }
+
+ @Test
+ void
testVerifyBeforeEnableUsesCurrentCredentialAndLeavesEnabledStateUntouched()
throws Exception {
+ final RecordingTokenCredential credential = new
RecordingTokenCredential(Mono.just(validToken(TOKEN_VALUE)));
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService(credential), false);
+ final AzureEntraDatabasePasswordProvider provider =
getProviderImplementation(runner);
+
+ final List<ConfigVerificationResult> results = runner.verify(provider,
Map.of());
+
+ assertEquals(1, credential.getGetTokenCount());
+ assertEquals(2, results.size());
+ assertVerificationResult(results.get(0),
AzureEntraDatabasePasswordProvider.VERIFY_CREDENTIALS_STEP, SUCCESSFUL,
+ "Resolved Azure credentials service and current
TokenCredential.");
+ assertVerificationResult(results.get(1),
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_STEP, SUCCESSFUL,
+ "DBCP Verify");
+
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
provider.getPassword(requestContext()));
+
assertEquals(AzureEntraDatabasePasswordProvider.FAILED_CREDENTIAL_RESOLUTION_MESSAGE,
exception.getMessage());
+ assertNull(exception.getCause());
+ }
+
+ @Test
+ void testVerifyUsesFreshCredentialWithoutMutatingEnabledState() throws
Exception {
+ final RecordingTokenCredential verifyCredential = new
RecordingTokenCredential(Mono.just(validToken(TOKEN_VALUE)));
+ final RecordingTokenCredential passwordCredential = new
RecordingTokenCredential(Mono.just(validToken(REFRESHED_TOKEN_VALUE)));
+ final TestAzureCredentialsService credentialsService = new
TestAzureCredentialsService(verifyCredential, passwordCredential);
+ final TestRunner runner = configureRunner(credentialsService);
+ final AzureEntraDatabasePasswordProvider provider =
getProviderImplementation(runner);
+
+ final List<ConfigVerificationResult> results = runner.verify(provider,
Map.of());
+ final char[] password = provider.getPassword(requestContext());
+
+ assertEquals(2, credentialsService.getGetCredentialsCount());
+ assertEquals(1, verifyCredential.getGetTokenCount());
+ assertEquals(1, passwordCredential.getGetTokenCount());
+ assertVerificationResult(results.get(1),
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_STEP, SUCCESSFUL, "DBCP
Verify");
+ assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), password);
+ }
+
+ @Test
+ void testVerifyCredentialsResolutionFailureIsSanitized() throws Exception {
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService(new IllegalStateException(LEAK_SENTINEL)), false);
+ final AzureEntraDatabasePasswordProvider provider =
getProviderImplementation(runner);
+
+ final List<ConfigVerificationResult> results = runner.verify(provider,
Map.of());
+
+ assertEquals(1, results.size());
+ assertVerificationResult(results.get(0),
AzureEntraDatabasePasswordProvider.VERIFY_CREDENTIALS_STEP, FAILED,
+
AzureEntraDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE);
+ assertFalse(results.get(0).getExplanation().contains(LEAK_SENTINEL));
+
assertNoSensitiveLogging(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID),
LEAK_SENTINEL);
+ }
+
+ @Test
+ void testVerifyNullCredentialFailsCredentialsStep() throws Exception {
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService((TokenCredential) null), false);
+
+ final List<ConfigVerificationResult> results =
runner.verify(getProviderImplementation(runner), Map.of());
+
+ assertEquals(1, results.size());
+ assertVerificationResult(results.get(0),
AzureEntraDatabasePasswordProvider.VERIFY_CREDENTIALS_STEP, FAILED,
+
AzureEntraDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidAccessTokens")
+ void testVerifyInvalidAccessTokenFailsTokenStep(final String testName,
final AccessToken accessToken) throws Exception {
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService(new StaticTokenCredential(accessToken)), false);
+
+ final List<ConfigVerificationResult> results =
runner.verify(getProviderImplementation(runner), Map.of());
+
+ assertEquals(2, results.size());
+ assertVerificationResult(results.get(1),
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_STEP, FAILED,
+
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("acceptedAccessTokens")
+ void testVerifyAcceptsAccessTokensWithoutFutureExpiration(final String
testName, final AccessToken accessToken,
+ final String
expectedPassword) throws Exception {
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService(new StaticTokenCredential(accessToken)), false);
+
+ final List<ConfigVerificationResult> results =
runner.verify(getProviderImplementation(runner), Map.of());
+
+ assertEquals(2, results.size());
+ assertVerificationResult(results.get(1),
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_STEP, SUCCESSFUL,
+ "DBCP Verify");
+ }
+
+ @Test
+ void testVerifyTokenAcquisitionTimeoutFailsTokenStep() throws Exception {
+ final AzureEntraDatabasePasswordProvider provider = new
AzureEntraDatabasePasswordProvider(SHORT_TOKEN_ACQUISITION_TIMEOUT);
+ final TestRunner runner = configureRunner(provider,
+ new TestAzureCredentialsService(new
RecordingTokenCredential(Mono.never())), false);
+
+ assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
+ final List<ConfigVerificationResult> results =
runner.verify(provider, Map.of());
+
+ assertEquals(2, results.size());
+ assertVerificationResult(results.get(1),
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_STEP, FAILED,
+
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED);
+ });
+ }
+
+ @Test
+ void testVerifyTokenAcquisitionFailureIsSanitized() throws Exception {
+ final TestRunner runner = configureRunner(
+ new TestAzureCredentialsService(new
RecordingTokenCredential(Mono.error(new IllegalStateException(LEAK_SENTINEL)))),
+ false
+ );
+
+ final List<ConfigVerificationResult> results =
runner.verify(getProviderImplementation(runner), Map.of());
+
+ assertEquals(2, results.size());
+ assertVerificationResult(results.get(1),
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_STEP, FAILED,
+
AzureEntraDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED);
+ assertFalse(results.get(1).getExplanation().contains(LEAK_SENTINEL));
+
assertNoSensitiveLogging(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID),
LEAK_SENTINEL);
+ }
+
+ @Test
+ void testVerifyUsesExactOssRdbmsScope() throws Exception {
+ final RecordingTokenCredential credential = new
RecordingTokenCredential(Mono.just(validToken(TOKEN_VALUE)));
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService(credential), false);
+
+ runner.verify(getProviderImplementation(runner), Map.of());
+
+
assertEquals(List.of(AzureEntraDatabasePasswordProvider.OSS_RDBMS_SCOPE),
credential.getLastRequestedScopes());
+ }
+
+ @Test
+ void testDisabledCallsFailBeforeEnable() throws Exception {
+ final DatabasePasswordProvider provider = getProvider(configureRunner(
+ new TestAzureCredentialsService(new
StaticTokenCredential(validToken(TOKEN_VALUE))), false));
+
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
provider.getPassword(requestContext()));
+
+
assertEquals(AzureEntraDatabasePasswordProvider.FAILED_CREDENTIAL_RESOLUTION_MESSAGE,
exception.getMessage());
+ assertNull(exception.getCause());
+ }
+
+ @Test
+ void testOnDisabledClearsRetainedCredentialsServiceAndFailsClosed() throws
Exception {
+ final TestRunner runner = configureRunner(new
TestAzureCredentialsService(new
StaticTokenCredential(validToken(TOKEN_VALUE))));
+ final AzureEntraDatabasePasswordProvider provider =
getProviderImplementation(runner);
+
+ runner.disableControllerService(provider);
+
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
provider.getPassword(requestContext()));
+
assertEquals(AzureEntraDatabasePasswordProvider.FAILED_CREDENTIAL_RESOLUTION_MESSAGE,
exception.getMessage());
+ assertNull(exception.getCause());
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidAccessTokens")
+ void testGetPasswordRejectsInvalidAccessToken(final String testName, final
AccessToken accessToken) throws Exception {
+ final DatabasePasswordProvider provider = getProvider(configureRunner(
+ new TestAzureCredentialsService(new
StaticTokenCredential(accessToken))));
+
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
provider.getPassword(requestContext()));
+
+
assertEquals(AzureEntraDatabasePasswordProvider.FAILED_TOKEN_ACQUISITION_MESSAGE,
exception.getMessage());
+ assertNull(exception.getCause());
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("acceptedAccessTokens")
+ void testGetPasswordPassesThroughAccessTokensWithoutFutureExpiration(final
String testName, final AccessToken accessToken,
+ final
String expectedPassword) throws Exception {
+ final DatabasePasswordProvider provider = getProvider(configureRunner(
+ new TestAzureCredentialsService(new
StaticTokenCredential(accessToken))));
+
+ final char[] password = provider.getPassword(requestContext());
+
+ assertArrayEquals(expectedPassword.toCharArray(), password);
+ }
+
+ @Test
+ void testGetPasswordTokenAcquisitionTimeoutFailsClosed() throws Exception {
+ final AzureEntraDatabasePasswordProvider provider = new
AzureEntraDatabasePasswordProvider(SHORT_TOKEN_ACQUISITION_TIMEOUT);
+ final DatabasePasswordProvider configuredProvider =
getProvider(configureRunner(provider,
+ new TestAzureCredentialsService(new
RecordingTokenCredential(Mono.never()))));
+
+ assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
configuredProvider.getPassword(requestContext()));
+
+
assertEquals(AzureEntraDatabasePasswordProvider.FAILED_TOKEN_ACQUISITION_MESSAGE,
exception.getMessage());
+ assertNull(exception.getCause());
+ });
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("sanitizedPasswordFailures")
+ void testPasswordGenerationFailuresAreSanitized(final String testName,
+ final
Supplier<TestAzureCredentialsService> credentialsServiceSupplier,
+ final String
expectedMessage) throws Exception {
+ final TestRunner runner =
configureRunner(credentialsServiceSupplier.get());
+ final DatabasePasswordProvider provider = getProvider(runner);
+
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
provider.getPassword(requestContext()));
+
+ assertEquals(expectedMessage, exception.getMessage());
+ assertNull(exception.getCause());
+ assertFalse(exception.getMessage().contains(LEAK_SENTINEL));
+
assertNoSensitiveLogging(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID),
LEAK_SENTINEL);
+ }
+
+ @Test
+ void testNullCredentialRejectedForPasswordGeneration() throws Exception {
+ final DatabasePasswordProvider provider =
getProvider(configureRunner(new TestAzureCredentialsService((TokenCredential)
null)));
+
+ final ProcessException exception =
assertThrows(ProcessException.class, () ->
provider.getPassword(requestContext()));
+
+
assertEquals(AzureEntraDatabasePasswordProvider.FAILED_CREDENTIAL_RESOLUTION_MESSAGE,
exception.getMessage());
+ assertNull(exception.getCause());
+ }
+
+ @Test
+ void testConcurrentGetPasswordUsesSeparateCredentialResolutionPerCall()
throws Exception {
+ final BlockingTokenCredential firstCredential = new
BlockingTokenCredential(validToken(TOKEN_VALUE));
+ final BlockingTokenCredential secondCredential = new
BlockingTokenCredential(validToken(REFRESHED_TOKEN_VALUE));
+ final TestAzureCredentialsService credentialsService = new
TestAzureCredentialsService(firstCredential, secondCredential);
+ final DatabasePasswordProvider provider =
getProvider(configureRunner(credentialsService));
+
+ executorService = Executors.newFixedThreadPool(2);
+ final CountDownLatch startLatch = new CountDownLatch(1);
+ final Future<char[]> first = executorService.submit(() ->
getPasswordAfterStart(provider, startLatch));
+ final Future<char[]> second = executorService.submit(() ->
getPasswordAfterStart(provider, startLatch));
+
+ startLatch.countDown();
+ assertTrue(firstCredential.awaitGetTokenEntry());
+ assertTrue(secondCredential.awaitGetTokenEntry());
+ firstCredential.releaseGetToken();
+ secondCredential.releaseGetToken();
+
+ final List<String> passwords = List.of(
+ new String(first.get(5, TimeUnit.SECONDS)),
+ new String(second.get(5, TimeUnit.SECONDS))
+ );
+
+ assertTrue(passwords.contains(TOKEN_VALUE));
+ assertTrue(passwords.contains(REFRESHED_TOKEN_VALUE));
+ assertEquals(2, credentialsService.getGetCredentialsCount());
+ assertEquals(1, firstCredential.getGetTokenCount());
+ assertEquals(1, secondCredential.getGetTokenCount());
+ }
+
+ @Test
+ void testControllerServiceRegistrationContainsProvider() throws
IOException {
+ final String resourcePath =
"META-INF/services/org.apache.nifi.controller.ControllerService";
+ try (InputStream inputStream =
AzureEntraDatabasePasswordProvider.class.getClassLoader().getResourceAsStream(resourcePath))
{
+ assertNotNull(inputStream);
+ final String registeredServices = new
String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+
assertTrue(registeredServices.contains(AzureEntraDatabasePasswordProvider.class.getName()));
+ }
+ }
+
+ private TestRunner configureRunner(final TestAzureCredentialsService
credentialsService) throws Exception {
+ return configureRunner(new AzureEntraDatabasePasswordProvider(),
credentialsService, true);
+ }
+
+ private TestRunner configureRunner(final TestAzureCredentialsService
credentialsService, final boolean enableProvider) throws Exception {
+ return configureRunner(new AzureEntraDatabasePasswordProvider(),
credentialsService, enableProvider);
+ }
+
+ private TestRunner configureRunner(final
AzureEntraDatabasePasswordProvider provider,
+ final TestAzureCredentialsService
credentialsService) throws Exception {
+ return configureRunner(provider, credentialsService, true);
+ }
+
+ private TestRunner configureRunner(final
AzureEntraDatabasePasswordProvider provider,
+ final TestAzureCredentialsService
credentialsService,
+ final boolean enableProvider) throws
Exception {
+ final TestRunner runner =
TestRunners.newTestRunner(NoOpProcessor.class);
+
+ runner.addControllerService(CREDENTIALS_SERVICE_ID,
credentialsService);
+ runner.enableControllerService(credentialsService);
+
+ runner.addControllerService(PASSWORD_PROVIDER_ID, provider);
+ runner.setProperty(provider,
AzureEntraDatabasePasswordProvider.AZURE_CREDENTIALS_SERVICE,
CREDENTIALS_SERVICE_ID);
+ if (enableProvider) {
+ runner.enableControllerService(provider);
+ runner.assertValid(provider);
+ }
+
+ return runner;
+ }
+
+ private DatabasePasswordProvider getProvider(final TestRunner runner) {
+ return (DatabasePasswordProvider) runner.getProcessContext()
+ .getControllerServiceLookup()
+ .getControllerService(PASSWORD_PROVIDER_ID);
+ }
+
+ private AzureEntraDatabasePasswordProvider getProviderImplementation(final
TestRunner runner) {
+ return (AzureEntraDatabasePasswordProvider) getProvider(runner);
+ }
+
+ private DatabasePasswordRequestContext requestContext() {
+ return DatabasePasswordRequestContext.builder()
+ .jdbcUrl(JDBC_URL)
+ .databaseUser(DATABASE_USER)
+ .driverClassName(DRIVER_CLASS)
+ .connectionProperties(Map.of())
+ .build();
+ }
+
+ private char[] getPasswordAfterStart(final DatabasePasswordProvider
provider, final CountDownLatch startLatch) throws InterruptedException {
+ startLatch.await(5, TimeUnit.SECONDS);
+ return provider.getPassword(requestContext());
+ }
+
+ private static Stream<Arguments> invalidAccessTokens() {
+ return Stream.of(
+ Arguments.of("null access token", null),
+ Arguments.of("null token string", token(null,
OffsetDateTime.now().plusMinutes(15))),
+ Arguments.of("blank token string", validToken(" "))
+ );
+ }
+
+ private static Stream<Arguments> acceptedAccessTokens() {
+ return Stream.of(
+ Arguments.of("expired token", expiredToken(TOKEN_VALUE),
TOKEN_VALUE),
+ Arguments.of("null expiration", token(REFRESHED_TOKEN_VALUE,
null), REFRESHED_TOKEN_VALUE)
+ );
+ }
+
+ private static Stream<Arguments> sanitizedPasswordFailures() {
+ return Stream.of(
+ Arguments.of("credentials resolution failure",
+ (Supplier<TestAzureCredentialsService>) () -> new
TestAzureCredentialsService(new IllegalStateException(LEAK_SENTINEL)),
+
AzureEntraDatabasePasswordProvider.FAILED_CREDENTIAL_RESOLUTION_MESSAGE),
+ Arguments.of("token acquisition failure",
+ (Supplier<TestAzureCredentialsService>) () -> new
TestAzureCredentialsService(
+ new RecordingTokenCredential(Mono.error(new
IllegalStateException(LEAK_SENTINEL)))),
+
AzureEntraDatabasePasswordProvider.FAILED_TOKEN_ACQUISITION_MESSAGE)
+ );
+ }
+
+ private static AccessToken validToken(final String tokenValue) {
+ return token(tokenValue, OffsetDateTime.now().plusMinutes(15));
+ }
+
+ private static AccessToken expiredToken(final String tokenValue) {
+ return token(tokenValue, OffsetDateTime.now().minusMinutes(15));
+ }
+
+ private static AccessToken token(final String tokenValue, final
OffsetDateTime expiresAt) {
+ return new AccessToken(tokenValue, expiresAt);
+ }
+
+ private static void assertVerificationResult(final
ConfigVerificationResult result, final String stepName,
+ final
ConfigVerificationResult.Outcome outcome, final String explanationFragment) {
+ assertEquals(stepName, result.getVerificationStepName());
+ assertEquals(outcome, result.getOutcome());
+ assertTrue(result.getExplanation().contains(explanationFragment),
result::getExplanation);
+ }
+
+ private static void assertNoSensitiveLogging(final MockComponentLog
logger, final String value) {
+ final List<LogMessage> logMessages = new ArrayList<>();
+ logMessages.addAll(logger.getDebugMessages());
+ logMessages.addAll(logger.getInfoMessages());
+ logMessages.addAll(logger.getWarnMessages());
+ logMessages.addAll(logger.getErrorMessages());
+
+ for (final LogMessage logMessage : logMessages) {
+ final String rawMessage = logMessage.getMsg();
+ assertFalse(rawMessage != null && rawMessage.contains(value));
+ final Object[] args = logMessage.getArgs();
+ final String formattedMessage =
MessageFormatter.arrayFormat(rawMessage, args == null ? new Object[0] :
args).getMessage();
+ assertFalse(formattedMessage != null &&
formattedMessage.contains(value));
+ if (args != null) {
+ for (final Object arg : args) {
+ final String argValue = arg == null ? null :
arg.toString();
+ assertFalse(argValue != null && argValue.contains(value));
+ assertFalse(arg instanceof Throwable);
+ }
+ }
+ assertNull(logMessage.getThrowable());
+ }
+ }
+
+ private static final class TestAzureCredentialsService extends
AbstractControllerService implements AzureCredentialsService {
+ private final List<TokenCredential> credentials;
+ private final RuntimeException credentialsException;
+ private final AtomicInteger getCredentialsCount = new AtomicInteger();
+
+ private TestAzureCredentialsService(final TokenCredential...
credentials) {
+ this.credentials = new ArrayList<>(Arrays.asList(credentials));
+ this.credentialsException = null;
+ }
+
+ private TestAzureCredentialsService(final RuntimeException
credentialsException) {
+ this.credentials = List.of();
+ this.credentialsException = credentialsException;
+ }
+
+ @Override
+ public TokenCredential getCredentials() {
+ final int callIndex = getCredentialsCount.getAndIncrement();
+ if (credentialsException != null) {
+ throw credentialsException;
+ }
+
+ if (credentials.isEmpty()) {
+ return null;
+ }
+
+ return credentials.get(Math.min(callIndex, credentials.size() -
1));
+ }
+
+ private int getGetCredentialsCount() {
+ return getCredentialsCount.get();
+ }
+ }
+
+ private static class RecordingTokenCredential implements TokenCredential {
+ private final Mono<AccessToken> tokenMono;
+ private final AtomicInteger getTokenCount = new AtomicInteger();
+ private final List<List<String>> requestedScopes = new
CopyOnWriteArrayList<>();
+
+ private RecordingTokenCredential(final Mono<AccessToken> tokenMono) {
+ this.tokenMono = tokenMono;
+ }
+
+ @Override
+ public Mono<AccessToken> getToken(final TokenRequestContext request) {
+ getTokenCount.incrementAndGet();
+ requestedScopes.add(List.copyOf(request.getScopes()));
+ return tokenMono;
+ }
+
+ private int getGetTokenCount() {
+ return getTokenCount.get();
+ }
+
+ private List<String> getLastRequestedScopes() {
+ return requestedScopes.isEmpty() ? List.of() :
requestedScopes.get(requestedScopes.size() - 1);
+ }
+ }
+
+ private static final class StaticTokenCredential extends
RecordingTokenCredential {
+ private StaticTokenCredential(final AccessToken accessToken) {
+ super(Mono.justOrEmpty(accessToken));
+ }
+ }
+
+ private static final class BlockingTokenCredential implements
TokenCredential {
+ private final AccessToken accessToken;
+ private final CountDownLatch getTokenEnteredLatch = new
CountDownLatch(1);
+ private final CountDownLatch releaseGetTokenLatch = new
CountDownLatch(1);
+ private final AtomicInteger getTokenCount = new AtomicInteger();
+
+ private BlockingTokenCredential(final AccessToken accessToken) {
+ this.accessToken = accessToken;
+ }
+
+ @Override
+ public Mono<AccessToken> getToken(final TokenRequestContext request) {
+ getTokenCount.incrementAndGet();
+ return Mono.fromCallable(() -> {
+ getTokenEnteredLatch.countDown();
+ if (!releaseGetTokenLatch.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timed out waiting for
token release");
+ }
+ return accessToken;
+ });
+ }
+
+ private boolean awaitGetTokenEntry() throws InterruptedException {
+ return getTokenEnteredLatch.await(5, TimeUnit.SECONDS);
+ }
+
+ private void releaseGetToken() {
+ releaseGetTokenLatch.countDown();
+ }
+
+ private int getGetTokenCount() {
+ return getTokenCount.get();
+ }
+ }
+}