diff --git a/src/Config/ObjectModel/DataSource.cs b/src/Config/ObjectModel/DataSource.cs
index e04acdfa37..9bfd40034a 100644
--- a/src/Config/ObjectModel/DataSource.cs
+++ b/src/Config/ObjectModel/DataSource.cs
@@ -137,19 +137,24 @@ public record MsSqlOptions(bool SetSessionContext = true) : IDataSourceOptions;
/// On-Behalf-Of flow. This allows DAB to connect to the database as the actual user, enabling
/// Row-Level Security (RLS) filtering based on user identity.
///
-/// OBO requires an Azure AD App Registration (separate from the DAB service's Managed Identity).
-/// The operator deploying DAB must set the following environment variables for the OBO App Registration,
-/// which DAB reads at startup via Environment.GetEnvironmentVariable():
-/// - DAB_OBO_CLIENT_ID: The Application (client) ID of the OBO App Registration
-/// - DAB_OBO_TENANT_ID: The Directory (tenant) ID where the OBO App Registration is registered
-/// - DAB_OBO_CLIENT_SECRET: The client secret of the OBO App Registration (not a user secret)
+/// OBO requires a Microsoft Entra API App Registration, separate from any Managed Identity used by DAB.
+/// The operator deploying DAB must set the following common environment variables, which DAB reads at startup:
+/// - DAB_OBO_CLIENT_ID: The Application (client) ID of the API App Registration, not a Managed Identity client ID
+/// - DAB_OBO_TENANT_ID: The Directory (tenant) ID where the API App Registration is registered
///
-/// These credentials belong to the OBO App Registration, which acts as a confidential client to exchange
-/// the incoming user JWT for a database access token. The user provides only their JWT; DAB uses the
-/// App Registration credentials to perform the OBO token exchange on their behalf.
+/// The credential method is process-wide and applies to all data sources with OBO enabled:
+/// - Client secret: Set only DAB_OBO_CLIENT_SECRET to the API App Registration's client secret.
+/// Existing deployments need no changes.
+/// - Managed identity federation: Set only DAB_OBO_MANAGED_IDENTITY_CLIENT_ID to the client ID of a
+/// same-tenant User-Assigned Managed Identity configured as a federated identity credential on the API App Registration.
+/// The Managed Identity client ID is distinct from the API App Registration ID in DAB_OBO_CLIENT_ID.
+/// DAB selects the method from the credential that is set. When OBO is enabled, configuring both or neither
+/// credential is an error. Blank values count as unset. Leave the other method's credential variable unset.
///
-/// These can be set in the hosting environment (e.g., Azure Container Apps secrets, Kubernetes secrets,
-/// Docker environment variables, or local shell environment).
+/// The API App Registration acts as a confidential client to exchange the incoming user JWT for a database
+/// access token. The user provides only their JWT; DAB uses the configured credential for the OBO exchange.
+/// Environment variables can be set in the hosting environment (e.g., Azure Container Apps secrets,
+/// Kubernetes secrets, Docker environment variables, or the local shell).
///
/// Note: DAB-specific prefixes (DAB_OBO_*) are used instead of AZURE_* to avoid conflict with
/// DefaultAzureCredential, which interprets AZURE_CLIENT_ID as a User-Assigned Managed Identity ID.
@@ -170,7 +175,8 @@ public record UserDelegatedAuthOptions(
public const int DEFAULT_TOKEN_CACHE_DURATION_MINUTES = 45;
///
- /// Environment variable name for OBO App Registration client ID.
+ /// Environment variable name for the API App Registration client ID, required for either OBO credential method.
+ /// This is not the client ID of the Managed Identity used for federation.
/// Uses DAB-specific prefix to avoid conflict with AZURE_CLIENT_ID which is
/// interpreted by DefaultAzureCredential/ManagedIdentityCredential as a
/// User-Assigned Managed Identity ID.
@@ -178,14 +184,22 @@ public record UserDelegatedAuthOptions(
public const string DAB_OBO_CLIENT_ID_ENV_VAR = "DAB_OBO_CLIENT_ID";
///
- /// Environment variable name for OBO App Registration client secret.
- /// Used for On-Behalf-Of token exchange.
+ /// Environment variable name for the API App Registration client secret.
+ /// Selects the client-secret OBO method when set; leave unset for managed identity federation.
///
public const string DAB_OBO_CLIENT_SECRET_ENV_VAR = "DAB_OBO_CLIENT_SECRET";
///
- /// Environment variable name for OBO tenant ID.
+ /// Environment variable name for OBO tenant ID, required for either credential method.
/// Uses DAB-specific prefix for consistency with OBO client ID.
///
public const string DAB_OBO_TENANT_ID_ENV_VAR = "DAB_OBO_TENANT_ID";
+
+ ///
+ /// Environment variable name for the User-Assigned Managed Identity client ID; selects federation when set.
+ /// The identity must be in the API App Registration's tenant and configured as its federated identity credential.
+ /// This is distinct from DAB_OBO_CLIENT_ID; its selection is independent of AZURE_CLIENT_ID and startup metadata access.
+ /// Leave unset for client-secret authentication.
+ ///
+ public const string DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR = "DAB_OBO_MANAGED_IDENTITY_CLIENT_ID";
}
diff --git a/src/Core/AuthenticationHelpers/OboCredentialFactory.cs b/src/Core/AuthenticationHelpers/OboCredentialFactory.cs
new file mode 100644
index 0000000000..6793281651
--- /dev/null
+++ b/src/Core/AuthenticationHelpers/OboCredentialFactory.cs
@@ -0,0 +1,184 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using Azure.Core;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Client;
+
+namespace Azure.DataApiBuilder.Core.AuthenticationHelpers
+{
+ ///
+ /// Validates and creates the application credential used for user-delegated authentication.
+ ///
+ public static class OboCredentialFactory
+ {
+ public const string MISSING_CREDENTIALS_ERROR_MESSAGE =
+ "User-delegated authentication requires DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, and exactly one of DAB_OBO_CLIENT_SECRET or DAB_OBO_MANAGED_IDENTITY_CLIENT_ID.";
+
+ private const string CONFLICTING_CREDENTIALS_ERROR_MESSAGE =
+ "Configure exactly one OBO credential method. DAB_OBO_CLIENT_SECRET and DAB_OBO_MANAGED_IDENTITY_CLIENT_ID cannot both be set.";
+
+ private const string TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default";
+
+ ///
+ /// Validates the environment variables for the selected OBO application credential.
+ /// Callers are responsible for checking whether user-delegated authentication is enabled.
+ ///
+ public static void ValidateEnvironmentVariables()
+ {
+ ValidateEnvironmentVariables(Environment.GetEnvironmentVariable);
+ }
+
+ ///
+ /// Creates a confidential client whose credential is independent of the incoming user assertion.
+ /// The returned client should be reused for subsequent OBO requests.
+ ///
+ public static IConfidentialClientApplication CreateClient(ILogger logger)
+ {
+ return CreateClient(logger, Environment.GetEnvironmentVariable);
+ }
+
+ internal static void ValidateEnvironmentVariables(Func environmentVariableReader)
+ {
+ _ = ReadOptions(environmentVariableReader);
+ }
+
+ internal static IConfidentialClientApplication CreateClient(
+ ILogger logger,
+ Func environmentVariableReader,
+ Func? managedIdentityCredentialFactory = null,
+ IMsalHttpClientFactory? httpClientFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(logger);
+ OboCredentialOptions options = ReadOptions(environmentVariableReader);
+
+ ConfidentialClientApplicationBuilder builder = ConfidentialClientApplicationBuilder
+ .Create(options.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, options.TenantId);
+
+ if (options.ClientSecret is not null)
+ {
+ builder.WithClientSecret(options.ClientSecret);
+ }
+ else
+ {
+ TokenCredential credential = managedIdentityCredentialFactory is null
+ ? new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(options.ManagedIdentityClientId!))
+ : managedIdentityCredentialFactory(options.ManagedIdentityClientId!);
+
+ // Reuse the credential, not a token snapshot: Azure Identity handles assertion refresh.
+ builder.WithClientAssertion((AssertionRequestOptions requestOptions) =>
+ GetManagedIdentityAssertionAsync(credential, logger, requestOptions.CancellationToken));
+ }
+
+ if (httpClientFactory is not null)
+ {
+ builder.WithHttpClientFactory(httpClientFactory);
+ }
+
+ return builder.Build();
+ }
+
+ private static async Task GetManagedIdentityAssertionAsync(
+ TokenCredential credential,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ AccessToken assertion = await credential.GetTokenAsync(
+ new TokenRequestContext([TOKEN_EXCHANGE_SCOPE]),
+ cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrWhiteSpace(assertion.Token) || assertion.ExpiresOn <= DateTimeOffset.UtcNow)
+ {
+ throw CreateAuthenticationFailure(logger);
+ }
+
+ return assertion.Token;
+ }
+ catch (AuthenticationFailedException)
+ {
+ // CredentialUnavailableException derives from AuthenticationFailedException.
+ cancellationToken.ThrowIfCancellationRequested();
+ throw CreateAuthenticationFailure(logger);
+ }
+ }
+
+ private static DataApiBuilderException CreateAuthenticationFailure(ILogger logger)
+ {
+ // SDK exceptions can contain credential responses. Do not log or retain their payloads.
+ logger.LogError(
+ "{EventType}: Unable to acquire the managed identity client assertion for user-delegated authentication.",
+ "OboClientAssertionAcquisitionFailed");
+
+ return new DataApiBuilderException(
+ message: DataApiBuilderException.OBO_TOKEN_ACQUISITION_FAILED,
+ statusCode: HttpStatusCode.Unauthorized,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.OboAuthenticationFailure);
+ }
+
+ private static OboCredentialOptions ReadOptions(Func environmentVariableReader)
+ {
+ ArgumentNullException.ThrowIfNull(environmentVariableReader);
+
+ string? clientId = environmentVariableReader(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR);
+ string? tenantId = environmentVariableReader(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR);
+ string? clientSecret = environmentVariableReader(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR);
+ string? managedIdentityClientId = environmentVariableReader(UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR);
+ bool hasClientSecret = !string.IsNullOrWhiteSpace(clientSecret);
+ bool hasManagedIdentity = !string.IsNullOrWhiteSpace(managedIdentityClientId);
+
+ if (hasClientSecret && hasManagedIdentity)
+ {
+ throw CreateConfigurationFailure(CONFLICTING_CREDENTIALS_ERROR_MESSAGE);
+ }
+
+ if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(tenantId)
+ || (!hasClientSecret && !hasManagedIdentity))
+ {
+ throw CreateConfigurationFailure(MISSING_CREDENTIALS_ERROR_MESSAGE);
+ }
+
+ return new OboCredentialOptions(
+ clientId,
+ tenantId,
+ hasClientSecret ? clientSecret : null,
+ hasManagedIdentity ? managedIdentityClientId : null);
+ }
+
+ private static DataApiBuilderException CreateConfigurationFailure(string message)
+ {
+ return new DataApiBuilderException(
+ message: message,
+ statusCode: HttpStatusCode.ServiceUnavailable,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
+ }
+
+ private sealed class OboCredentialOptions
+ {
+ public string ClientId { get; }
+ public string TenantId { get; }
+ public string? ClientSecret { get; }
+ public string? ManagedIdentityClientId { get; }
+
+ public OboCredentialOptions(
+ string clientId,
+ string tenantId,
+ string? clientSecret,
+ string? managedIdentityClientId)
+ {
+ ClientId = clientId;
+ TenantId = tenantId;
+ ClientSecret = clientSecret;
+ ManagedIdentityClientId = managedIdentityClientId;
+ }
+ }
+ }
+}
diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs
index 222b15647d..a904528636 100644
--- a/src/Core/Configurations/RuntimeConfigValidator.cs
+++ b/src/Core/Configurations/RuntimeConfigValidator.cs
@@ -62,7 +62,7 @@ public class RuntimeConfigValidator : IConfigValidator
"runtime.cache.enabled must be false when user-delegated-auth is configured.";
public const string USER_DELEGATED_AUTH_MISSING_CREDENTIALS_ERR_MSG =
- "User-delegated authentication requires DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, and DAB_OBO_CLIENT_SECRET environment variables.";
+ OboCredentialFactory.MISSING_CREDENTIALS_ERROR_MESSAGE;
// Error messages.
public const string INVALID_CLAIMS_IN_POLICY_ERR_MSG = "One or more claim types supplied in the database policy are not supported.";
@@ -129,16 +129,18 @@ public void ValidateDataSourceInConfig(
///
/// Validates configuration for user-delegated authentication (OBO).
- /// When any data source has user-delegated-auth configured, the following
+ /// When any data source has user-delegated-auth enabled, the following
/// rules are enforced:
/// - data-source.database-type must be "mssql".
/// - data-source.user-delegated-auth.database-audience must be present.
/// - runtime.cache.enabled must be false.
- /// - Environment variables DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, and DAB_OBO_CLIENT_SECRET must be set.
+ /// - Environment variables must supply the API app IDs and exactly one process-wide OBO credential: client secret or managed identity.
///
/// Runtime configuration.
private void ValidateUserDelegatedAuthOptions(RuntimeConfig runtimeConfig)
{
+ bool hasValidatedGlobalOptions = false;
+
foreach (DataSource dataSource in runtimeConfig.ListAllDataSources())
{
// Skip validation if user-delegated-auth is not configured or not enabled
@@ -163,26 +165,27 @@ private void ValidateUserDelegatedAuthOptions(RuntimeConfig runtimeConfig)
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
}
- // Validate OBO App Registration credentials are configured via environment variables.
- string? clientId = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR);
- string? tenantId = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR);
- string? clientSecret = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR);
-
- if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientSecret))
+ // Credentials and result caching are process-wide; record their errors only once.
+ if (!hasValidatedGlobalOptions)
{
- HandleOrRecordException(new DataApiBuilderException(
- message: USER_DELEGATED_AUTH_MISSING_CREDENTIALS_ERR_MSG,
- statusCode: HttpStatusCode.ServiceUnavailable,
- subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
- }
+ try
+ {
+ OboCredentialFactory.ValidateEnvironmentVariables();
+ }
+ catch (DataApiBuilderException ex) when (ex.SubStatusCode == DataApiBuilderException.SubStatusCodes.ConfigValidationError)
+ {
+ HandleOrRecordException(ex);
+ }
- // Validate caching is disabled when user-delegated-auth is enabled
- if (runtimeConfig.Runtime?.Cache?.Enabled == true)
- {
- HandleOrRecordException(new DataApiBuilderException(
- message: USER_DELEGATED_AUTH_CACHING_ERR_MSG,
- statusCode: HttpStatusCode.ServiceUnavailable,
- subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
+ if (runtimeConfig.Runtime?.Cache?.Enabled == true)
+ {
+ HandleOrRecordException(new DataApiBuilderException(
+ message: USER_DELEGATED_AUTH_CACHING_ERR_MSG,
+ statusCode: HttpStatusCode.ServiceUnavailable,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
+ }
+
+ hasValidatedGlobalOptions = true;
}
}
}
diff --git a/src/Service.Tests/Authentication/OboCredentialFactoryTests.cs b/src/Service.Tests/Authentication/OboCredentialFactoryTests.cs
new file mode 100644
index 0000000000..c7fec46e79
--- /dev/null
+++ b/src/Service.Tests/Authentication/OboCredentialFactoryTests.cs
@@ -0,0 +1,593 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Core;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.AuthenticationHelpers;
+using Azure.DataApiBuilder.Core.Resolvers;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Identity.Client;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.Authentication
+{
+ [TestClass]
+ public class OboCredentialFactoryTests
+ {
+ private const string CLIENT_ID = "11111111-1111-1111-1111-111111111111";
+ private const string TENANT_ID = "22222222-2222-2222-2222-222222222222";
+ private const string MANAGED_IDENTITY_CLIENT_ID = "33333333-3333-3333-3333-333333333333";
+ private const string CLIENT_SECRET = " unit-test-only+secret=& ";
+ private const string USER_ASSERTION = "unit-test-only.user.assertion";
+ private const string MANAGED_IDENTITY_ASSERTION = "unit-test-only-managed-identity-assertion";
+ private const string SQL_ACCESS_TOKEN = "unit-test-only-sql-access-token";
+ private const string SQL_SCOPE = "https://database.windows.net/.default";
+ private const string TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default";
+ private const string AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID;
+ private const string SDK_FAILURE_PAYLOAD = "unit-test-only-private-sdk-response";
+ private const string MISSING_CREDENTIALS_ERROR_MESSAGE =
+ "User-delegated authentication requires DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, and exactly one of DAB_OBO_CLIENT_SECRET or DAB_OBO_MANAGED_IDENTITY_CLIENT_ID.";
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" \t")]
+ public async Task CreateClient_WorkloadIdentityUsesSelectedIdentityAndSeparateOboAssertions(string? clientSecret)
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ environment[UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = clientSecret;
+ environment["AZURE_CLIENT_ID"] = "unrelated-startup-identity";
+ environment["AZURE_CLIENT_SECRET"] = "unrelated-default-credential-secret";
+ environment["AZURE_FEDERATED_TOKEN_FILE"] = "not-an-obo-token-file";
+
+ FakeTokenCredential credential = new((_, _) => ValueTask.FromResult(
+ new AccessToken(MANAGED_IDENTITY_ASSERTION, DateTimeOffset.UtcNow.AddHours(1))));
+ string? selectedIdentity = null;
+ using StubMsalHttpClientFactory httpFactory = new();
+ using CancellationTokenSource cancellation = new();
+
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ NullLogger.Instance,
+ name => environment.GetValueOrDefault(name),
+ managedIdentityCredentialFactory: clientId =>
+ {
+ selectedIdentity = clientId;
+ return credential;
+ },
+ httpClientFactory: httpFactory);
+
+ Assert.AreEqual(MANAGED_IDENTITY_CLIENT_ID, selectedIdentity);
+ Assert.AreEqual(0, credential.Requests.Count, "Client construction must not acquire an assertion.");
+ Assert.AreEqual(0, httpFactory.RequestCount);
+ Assert.AreEqual(AUTHORITY, client.Authority.TrimEnd('/'));
+
+ MsalClientWrapper wrapper = new(client);
+ AuthenticationResult result = await wrapper.AcquireTokenOnBehalfOfAsync(
+ [SQL_SCOPE], USER_ASSERTION, cancellation.Token);
+
+ Assert.AreEqual(SQL_ACCESS_TOKEN, result.AccessToken);
+ Assert.AreEqual(1, credential.Requests.Count);
+ CollectionAssert.AreEqual(new[] { TOKEN_EXCHANGE_SCOPE }, credential.Requests[0].Scopes);
+ Assert.AreEqual(cancellation.Token, credential.CancellationTokens[0]);
+ Assert.AreEqual(1, httpFactory.TokenRequests.Count);
+
+ IReadOnlyDictionary request = httpFactory.TokenRequests[0];
+ AssertOboRequest(request);
+ Assert.AreEqual(MANAGED_IDENTITY_ASSERTION, request["client_assertion"]);
+ Assert.AreEqual("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", request["client_assertion_type"]);
+ Assert.IsFalse(request.ContainsKey("client_secret"));
+ }
+
+ [TestMethod]
+ public async Task CreateClient_WorkloadIdentityReusesCredentialAndRefreshesAssertion()
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ int credentialInstances = 0;
+ int assertionsIssued = 0;
+ FakeTokenCredential credential = new((_, _) => ValueTask.FromResult(
+ new AccessToken($"{MANAGED_IDENTITY_ASSERTION}-{++assertionsIssued}", DateTimeOffset.UtcNow.AddHours(1))));
+ using StubMsalHttpClientFactory httpFactory = new();
+
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ NullLogger.Instance,
+ name => environment.GetValueOrDefault(name),
+ managedIdentityCredentialFactory: _ =>
+ {
+ credentialInstances++;
+ return credential;
+ },
+ httpClientFactory: httpFactory);
+
+ await client.AcquireTokenOnBehalfOf([SQL_SCOPE], new UserAssertion(USER_ASSERTION)).ExecuteAsync();
+
+ // Force a fresh OBO acquisition rather than reading MSAL's SQL access-token cache.
+ await client.AcquireTokenOnBehalfOf([SQL_SCOPE], new UserAssertion(USER_ASSERTION))
+ .WithForceRefresh(true)
+ .ExecuteAsync();
+
+ Assert.AreEqual(1, credentialInstances);
+ Assert.AreEqual(2, credential.Requests.Count);
+ Assert.AreEqual(2, httpFactory.TokenRequests.Count);
+ Assert.AreEqual($"{MANAGED_IDENTITY_ASSERTION}-1", httpFactory.TokenRequests[0]["client_assertion"]);
+ Assert.AreEqual($"{MANAGED_IDENTITY_ASSERTION}-2", httpFactory.TokenRequests[1]["client_assertion"]);
+
+ foreach (TokenRequestContext request in credential.Requests)
+ {
+ CollectionAssert.AreEqual(new[] { TOKEN_EXCHANGE_SCOPE }, request.Scopes);
+ }
+
+ foreach (IReadOnlyDictionary request in httpFactory.TokenRequests)
+ {
+ AssertOboRequest(request);
+ Assert.IsFalse(request.ContainsKey("client_secret"));
+ }
+ }
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" \t")]
+ public async Task CreateClient_ClientSecretPreservesLegacyBehaviorAndSecretBytes(string? managedIdentityClientId)
+ {
+ Dictionary environment = new()
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = CLIENT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = TENANT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = CLIENT_SECRET,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = managedIdentityClientId,
+ ["AZURE_CLIENT_ID"] = "unrelated-startup-identity"
+ };
+ using StubMsalHttpClientFactory httpFactory = new();
+
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ NullLogger.Instance,
+ name => environment.GetValueOrDefault(name),
+ managedIdentityCredentialFactory: _ => throw new AssertFailedException("ClientSecret must not create a managed identity credential."),
+ httpClientFactory: httpFactory);
+
+ MsalClientWrapper wrapper = new(client);
+ AuthenticationResult result = await wrapper.AcquireTokenOnBehalfOfAsync([SQL_SCOPE], USER_ASSERTION);
+
+ Assert.AreEqual(SQL_ACCESS_TOKEN, result.AccessToken);
+ Assert.AreEqual(1, httpFactory.TokenRequests.Count);
+ IReadOnlyDictionary request = httpFactory.TokenRequests[0];
+ AssertOboRequest(request);
+ Assert.AreEqual(CLIENT_SECRET, request["client_secret"], "Nonblank secret bytes must not be trimmed.");
+ Assert.IsFalse(request.ContainsKey("client_assertion"));
+ Assert.IsFalse(request.ContainsKey("client_assertion_type"));
+ }
+
+ [DataTestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public async Task CreateClient_ManagedIdentityFailureIsSanitizedWithoutFallback(bool unavailable)
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ environment["AZURE_CLIENT_SECRET"] = CLIENT_SECRET;
+ string sdkMessage = $"{SDK_FAILURE_PAYLOAD} {CLIENT_SECRET} {USER_ASSERTION} {MANAGED_IDENTITY_ASSERTION}";
+ Exception sdkException = unavailable
+ ? new CredentialUnavailableException(sdkMessage, new InvalidOperationException(sdkMessage))
+ : new AuthenticationFailedException(sdkMessage, new InvalidOperationException(sdkMessage));
+ FakeTokenCredential credential = new((_, _) => ValueTask.FromException(sdkException));
+ Mock logger = new();
+ using StubMsalHttpClientFactory httpFactory = new();
+
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ logger.Object,
+ name => environment.GetValueOrDefault(name),
+ _ => credential,
+ httpFactory);
+
+ DataApiBuilderException exception = await Assert.ThrowsExceptionAsync(() =>
+ client.AcquireTokenOnBehalfOf([SQL_SCOPE], new UserAssertion(USER_ASSERTION)).ExecuteAsync());
+
+ AssertAuthenticationFailure(exception, logger);
+ Assert.AreEqual(1, credential.Requests.Count);
+ Assert.AreEqual(0, httpFactory.TokenRequests.Count, "A failed assertion must not trigger a fallback OBO request.");
+ }
+
+ [DataTestMethod]
+ [DataRow(null, 3600)]
+ [DataRow("", 3600)]
+ [DataRow(" \t", 3600)]
+ [DataRow(MANAGED_IDENTITY_ASSERTION, -60)]
+ public async Task CreateClient_UnusableManagedIdentityTokenIsSanitized(string? token, int expiresInSeconds)
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ AccessToken assertion = token is null
+ ? default
+ : new AccessToken(token, DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds));
+ FakeTokenCredential credential = new((_, _) => ValueTask.FromResult(assertion));
+ Mock logger = new();
+ using StubMsalHttpClientFactory httpFactory = new();
+
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ logger.Object,
+ name => environment.GetValueOrDefault(name),
+ _ => credential,
+ httpFactory);
+
+ DataApiBuilderException exception = await Assert.ThrowsExceptionAsync(() =>
+ client.AcquireTokenOnBehalfOf([SQL_SCOPE], new UserAssertion(USER_ASSERTION)).ExecuteAsync());
+
+ AssertAuthenticationFailure(exception, logger);
+ Assert.AreEqual(1, credential.Requests.Count);
+ Assert.AreEqual(0, httpFactory.TokenRequests.Count);
+ }
+
+ [DataTestMethod]
+ [DataRow("OperationCanceled")]
+ [DataRow("AuthenticationFailed")]
+ [DataRow("CredentialUnavailable")]
+ public async Task CreateClient_AssertionCallbackPreservesCancellation(string failure)
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ using CancellationTokenSource cancellation = new();
+ FakeTokenCredential credential = new((_, token) =>
+ {
+ Assert.AreEqual(cancellation.Token, token);
+ cancellation.Cancel();
+ Exception exception = failure switch
+ {
+ "AuthenticationFailed" => new AuthenticationFailedException(SDK_FAILURE_PAYLOAD),
+ "CredentialUnavailable" => new CredentialUnavailableException(SDK_FAILURE_PAYLOAD),
+ _ => new OperationCanceledException(token)
+ };
+
+ return ValueTask.FromException(exception);
+ });
+ Mock logger = new();
+ using StubMsalHttpClientFactory httpFactory = new();
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ logger.Object,
+ name => environment.GetValueOrDefault(name),
+ _ => credential,
+ httpFactory);
+
+ await AssertCancellationAsync(
+ client.AcquireTokenOnBehalfOf([SQL_SCOPE], new UserAssertion(USER_ASSERTION)).ExecuteAsync(cancellation.Token),
+ cancellation.Token);
+
+ Assert.AreEqual(1, credential.Requests.Count);
+ Assert.AreEqual(0, httpFactory.TokenRequests.Count);
+ logger.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task CreateClient_AlreadyCanceledRequestDoesNotAcquireAssertion()
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ using CancellationTokenSource cancellation = new();
+ cancellation.Cancel();
+ FakeTokenCredential credential = new((_, _) => throw new AssertFailedException("A canceled request must not acquire an assertion."));
+ Mock logger = new();
+ using StubMsalHttpClientFactory httpFactory = new();
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ logger.Object,
+ name => environment.GetValueOrDefault(name),
+ _ => credential,
+ httpFactory);
+
+ await AssertCancellationAsync(
+ client.AcquireTokenOnBehalfOf([SQL_SCOPE], new UserAssertion(USER_ASSERTION)).ExecuteAsync(cancellation.Token),
+ cancellation.Token);
+
+ Assert.AreEqual(0, credential.Requests.Count);
+ Assert.AreEqual(0, httpFactory.TokenRequests.Count);
+ logger.VerifyNoOtherCalls();
+ }
+
+ [DataTestMethod]
+ [DataRow(CLIENT_SECRET, null)]
+ [DataRow(null, MANAGED_IDENTITY_CLIENT_ID)]
+ public void ValidateAndCreateClient_ReadOnlyCredentialEnvironmentVariables(
+ string? clientSecret,
+ string? managedIdentityClientId)
+ {
+ Dictionary environment = new()
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = CLIENT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = TENANT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = clientSecret,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = managedIdentityClientId
+ };
+ FakeTokenCredential credential = new((_, _) =>
+ throw new AssertFailedException("Configuration and client creation must not acquire tokens."));
+
+ string? ReadEnvironmentVariable(string name)
+ {
+ Assert.IsTrue(environment.ContainsKey(name), $"Unexpected environment variable: {name}");
+ return environment[name];
+ }
+
+ OboCredentialFactory.ValidateEnvironmentVariables(ReadEnvironmentVariable);
+ IConfidentialClientApplication client = OboCredentialFactory.CreateClient(
+ NullLogger.Instance, ReadEnvironmentVariable, _ => credential);
+
+ Assert.AreEqual(CLIENT_ID, client.AppConfig.ClientId);
+ Assert.AreEqual(0, credential.Requests.Count);
+ }
+
+ [DataTestMethod]
+ [DataRow(CLIENT_ID, TENANT_ID)]
+ [DataRow(null, null)]
+ [DataRow(" \t", " \t")]
+ public void ValidateAndCreateClient_RejectConflictingCredentials(string? clientId, string? tenantId)
+ {
+ Dictionary environment = CreateWorkloadIdentityEnvironment();
+ environment[UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = clientId;
+ environment[UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = tenantId;
+ environment[UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = CLIENT_SECRET;
+
+ AssertConfigurationFailure(environment,
+ "Configure exactly one OBO credential method. DAB_OBO_CLIENT_SECRET and DAB_OBO_MANAGED_IDENTITY_CLIENT_ID cannot both be set.");
+ }
+
+ [DataTestMethod]
+ [DataRow(null, null)]
+ [DataRow(null, "")]
+ [DataRow(null, " \t")]
+ [DataRow("", null)]
+ [DataRow("", "")]
+ [DataRow("", " \t")]
+ [DataRow(" \t", null)]
+ [DataRow(" \t", "")]
+ [DataRow(" \t", " \t")]
+ public void ValidateAndCreateClient_RejectNeitherCredential(string? clientSecret, string? managedIdentityClientId)
+ {
+ Dictionary environment = new()
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = CLIENT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = TENANT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = clientSecret,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = managedIdentityClientId,
+ ["AZURE_CLIENT_ID"] = "unrelated-startup-identity",
+ ["AZURE_CLIENT_SECRET"] = CLIENT_SECRET
+ };
+
+ AssertConfigurationFailure(environment, MISSING_CREDENTIALS_ERROR_MESSAGE);
+ }
+
+ [DataTestMethod]
+ [DataRow(CLIENT_SECRET, null, UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR)]
+ [DataRow(CLIENT_SECRET, null, UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR)]
+ [DataRow(null, MANAGED_IDENTITY_CLIENT_ID, UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR)]
+ [DataRow(null, MANAGED_IDENTITY_CLIENT_ID, UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR)]
+ public void ValidateAndCreateClient_RequireCommonIdsForEitherCredential(
+ string? clientSecret,
+ string? managedIdentityClientId,
+ string variableName)
+ {
+ Dictionary environment = new()
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = CLIENT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = TENANT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = clientSecret,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = managedIdentityClientId
+ };
+
+ foreach (string? missingValue in new[] { null, "", " \t" })
+ {
+ environment[variableName] = missingValue;
+ AssertConfigurationFailure(environment, MISSING_CREDENTIALS_ERROR_MESSAGE);
+ }
+ }
+
+ private static Dictionary CreateWorkloadIdentityEnvironment()
+ {
+ return new Dictionary
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = CLIENT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = TENANT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = MANAGED_IDENTITY_CLIENT_ID
+ };
+ }
+
+ private static void AssertOboRequest(IReadOnlyDictionary request)
+ {
+ Assert.AreEqual(CLIENT_ID, request["client_id"]);
+ Assert.AreEqual(USER_ASSERTION, request["assertion"]);
+ Assert.AreEqual("on_behalf_of", request["requested_token_use"]);
+ Assert.AreEqual("urn:ietf:params:oauth:grant-type:jwt-bearer", request["grant_type"]);
+
+ // MSAL can add OpenID Connect protocol scopes; the sole resource scope must remain SQL.
+ string[] resourceScopes = request["scope"].Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Except(["openid", "profile", "offline_access"])
+ .ToArray();
+ CollectionAssert.AreEquivalent(new[] { SQL_SCOPE }, resourceScopes);
+ }
+
+ private static void AssertAuthenticationFailure(DataApiBuilderException exception, Mock logger)
+ {
+ Assert.AreEqual(HttpStatusCode.Unauthorized, exception.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.OboAuthenticationFailure, exception.SubStatusCode);
+ Assert.AreEqual(DataApiBuilderException.OBO_TOKEN_ACQUISITION_FAILED, exception.Message);
+ Assert.IsNull(exception.InnerException);
+ AssertNoCredentialDisclosure(exception.ToString());
+
+ IInvocation[] logCalls = logger.Invocations.Where(call => call.Method.Name == nameof(ILogger.Log)).ToArray();
+ Assert.AreEqual(1, logCalls.Length);
+ Assert.AreEqual(Microsoft.Extensions.Logging.LogLevel.Error, logCalls[0].Arguments[0]);
+ Assert.IsNull(logCalls[0].Arguments[3], "The SDK exception must not be passed to the logger.");
+ AssertNoCredentialDisclosure(logCalls[0].Arguments[2].ToString()!);
+ }
+
+ private static void AssertConfigurationFailure(Dictionary environment, string expectedMessage)
+ {
+ DataApiBuilderException validationException = Assert.ThrowsException(() =>
+ OboCredentialFactory.ValidateEnvironmentVariables(name => environment.GetValueOrDefault(name)));
+ DataApiBuilderException creationException = Assert.ThrowsException(() =>
+ OboCredentialFactory.CreateClient(
+ NullLogger.Instance,
+ name => environment.GetValueOrDefault(name),
+ managedIdentityCredentialFactory: _ => throw new AssertFailedException("Invalid configuration must not create a credential.")));
+
+ foreach (DataApiBuilderException exception in new[] { validationException, creationException })
+ {
+ Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode);
+ Assert.AreEqual(expectedMessage, exception.Message);
+ Assert.IsNull(exception.InnerException);
+ AssertNoCredentialDisclosure(exception.ToString());
+ }
+ }
+
+ private static void AssertNoCredentialDisclosure(string text)
+ {
+ foreach (string value in new[] { CLIENT_ID, TENANT_ID, MANAGED_IDENTITY_CLIENT_ID, CLIENT_SECRET, USER_ASSERTION, MANAGED_IDENTITY_ASSERTION, SDK_FAILURE_PAYLOAD })
+ {
+ Assert.IsFalse(text.Contains(value, StringComparison.Ordinal), "Credential values and SDK payloads must not be disclosed.");
+ }
+ }
+
+ private static async Task AssertCancellationAsync(Task acquisition, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await acquisition;
+ Assert.Fail("Token acquisition should propagate cancellation.");
+ }
+ catch (OperationCanceledException exception)
+ {
+ Assert.AreEqual(cancellationToken, exception.CancellationToken);
+ }
+ }
+
+ private sealed class FakeTokenCredential : TokenCredential
+ {
+ private readonly Func> _getToken;
+
+ public List Requests { get; } = [];
+ public List CancellationTokens { get; } = [];
+
+ public FakeTokenCredential(Func> getToken)
+ {
+ _getToken = getToken;
+ }
+
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ throw new AssertFailedException("Managed identity assertions must be acquired asynchronously.");
+ }
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ Requests.Add(requestContext);
+ CancellationTokens.Add(cancellationToken);
+ return _getToken(requestContext, cancellationToken);
+ }
+ }
+
+ private sealed class StubMsalHttpClientFactory : HttpMessageHandler, IMsalHttpClientFactory
+ {
+ private readonly HttpClient _httpClient;
+
+ public List> TokenRequests { get; } = [];
+ public int RequestCount { get; private set; }
+
+ public StubMsalHttpClientFactory()
+ {
+ _httpClient = new HttpClient(this, disposeHandler: false);
+ }
+
+ public HttpClient GetHttpClient()
+ {
+ return _httpClient;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ RequestCount++;
+
+ Uri uri = request.RequestUri!;
+ if (request.Method == HttpMethod.Get
+ && uri.GetLeftPart(UriPartial.Path) == "https://login.microsoftonline.com/common/discovery/instance")
+ {
+ return JsonResponse(new
+ {
+ tenant_discovery_endpoint = $"{AUTHORITY}/v2.0/.well-known/openid-configuration",
+ metadata = new[]
+ {
+ new
+ {
+ preferred_network = "login.microsoftonline.com",
+ preferred_cache = "login.microsoftonline.com",
+ aliases = new[] { "login.microsoftonline.com", "login.windows.net", "sts.windows.net" }
+ }
+ }
+ });
+ }
+
+ if (request.Method == HttpMethod.Get
+ && uri.GetLeftPart(UriPartial.Path) == $"{AUTHORITY}/v2.0/.well-known/openid-configuration")
+ {
+ return JsonResponse(new
+ {
+ authorization_endpoint = $"{AUTHORITY}/oauth2/v2.0/authorize",
+ token_endpoint = $"{AUTHORITY}/oauth2/v2.0/token",
+ issuer = $"{AUTHORITY}/v2.0"
+ });
+ }
+
+ if (request.Method == HttpMethod.Post
+ && uri.GetLeftPart(UriPartial.Path) == $"{AUTHORITY}/oauth2/v2.0/token")
+ {
+ string body = await request.Content!.ReadAsStringAsync(cancellationToken);
+ Dictionary parameters = body.Split('&')
+ .Select(parameter => parameter.Split('=', 2))
+ .ToDictionary(parameter => WebUtility.UrlDecode(parameter[0]), parameter => WebUtility.UrlDecode(parameter[1]));
+ TokenRequests.Add(parameters);
+
+ string clientInfo = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
+ {
+ uid = "44444444-4444-4444-4444-444444444444",
+ utid = TENANT_ID
+ }))).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ return JsonResponse(new
+ {
+ token_type = "Bearer",
+ access_token = SQL_ACCESS_TOKEN,
+ expires_in = 3600,
+ scope = SQL_SCOPE,
+ client_info = clientInfo
+ });
+ }
+
+ throw new AssertFailedException($"Unexpected MSAL HTTP request: {request.Method} {uri.GetLeftPart(UriPartial.Path)}");
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _httpClient.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private static HttpResponseMessage JsonResponse(object response)
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(response), Encoding.UTF8, "application/json")
+ };
+ }
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
index 522a5d00d5..0877f9d328 100644
--- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
+++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
@@ -13,6 +13,7 @@
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.Converters;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.NamingPolicies;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Config.ObjectModel.Embeddings;
using Azure.DataApiBuilder.Core.Configurations;
@@ -35,8 +36,14 @@ namespace Azure.DataApiBuilder.Service.Tests.UnitTests
/// the tests focus on the permissions portion of the entities property within the RuntimeConfig object.
///
[TestClass]
+ [DoNotParallelize]
public class ConfigValidationUnitTests
{
+ private const string OBO_MISSING_CREDENTIALS_ERROR_MESSAGE =
+ "User-delegated authentication requires DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, and exactly one of DAB_OBO_CLIENT_SECRET or DAB_OBO_MANAGED_IDENTITY_CLIENT_ID.";
+ private const string OBO_CREDENTIAL_CONFLICT_ERROR_MESSAGE =
+ "Configure exactly one OBO credential method. DAB_OBO_CLIENT_SECRET and DAB_OBO_MANAGED_IDENTITY_CLIENT_ID cannot both be set.";
+
///
/// Test method to validate that an appropriate exception is thrown when database policy tries to reference a field
/// which is not accessible.
@@ -2398,6 +2405,7 @@ public void UserDelegatedAuthRequiresMssqlDatabaseType()
DataApiBuilderException dabException = Assert.ThrowsException(
() => configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, loggerMock.Object));
+ Assert.AreEqual(expected: RuntimeConfigValidator.USER_DELEGATED_AUTH_DATABASE_TYPE_ERR_MSG, actual: dabException.Message);
Assert.AreEqual(expected: HttpStatusCode.ServiceUnavailable, actual: dabException.StatusCode);
Assert.AreEqual(expected: DataApiBuilderException.SubStatusCodes.ConfigValidationError, actual: dabException.SubStatusCode);
}
@@ -2438,20 +2446,17 @@ public void UserDelegatedAuthRequiresDatabaseAudience()
DataApiBuilderException dabException = Assert.ThrowsException(
() => configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, loggerMock.Object));
+ Assert.AreEqual(expected: RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG, actual: dabException.Message);
Assert.AreEqual(expected: HttpStatusCode.ServiceUnavailable, actual: dabException.StatusCode);
Assert.AreEqual(expected: DataApiBuilderException.SubStatusCodes.ConfigValidationError, actual: dabException.SubStatusCode);
}
- [TestMethod]
- public void UserDelegatedAuthRequiresCachingDisabled()
+ [DataTestMethod]
+ [DataRow("test-client-secret", null, DisplayName = "Legacy client secret requires result caching disabled")]
+ [DataRow(null, "test-managed-identity-client-id", DisplayName = "Workload identity requires result caching disabled")]
+ public void UserDelegatedAuthRequiresCachingDisabled(string clientSecret, string managedIdentityClientId)
{
- // Arrange - Set environment variables for Azure AD credentials to ensure
- // validation reaches the caching check (not failing on missing credentials)
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, "test-client-id");
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, "test-client-secret");
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, "test-tenant-id");
-
- try
+ WithUserDelegatedAuthEnvironment(() =>
{
string runtimeConfigString = @"{
""$schema"": ""test_schema"",
@@ -2493,14 +2498,7 @@ public void UserDelegatedAuthRequiresCachingDisabled()
Assert.AreEqual(expected: RuntimeConfigValidator.USER_DELEGATED_AUTH_CACHING_ERR_MSG, actual: dabException.Message);
Assert.AreEqual(expected: HttpStatusCode.ServiceUnavailable, actual: dabException.StatusCode);
Assert.AreEqual(expected: DataApiBuilderException.SubStatusCodes.ConfigValidationError, actual: dabException.SubStatusCode);
- }
- finally
- {
- // Clean up environment variables
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, null);
- }
+ }, clientSecret: clientSecret, managedIdentityClientId: managedIdentityClientId);
}
///
@@ -2549,17 +2547,18 @@ public void ValidateUserDelegatedAuth_InvalidDatabaseAudience_ThrowsError(string
}
///
- /// Test to validate that user-delegated-auth with MSSQL, valid audience, and caching disabled passes validation.
+ /// Either credential alone selects its method; blank values for the other credential count as unset.
///
- [TestMethod]
- public void ValidateUserDelegatedAuth_ValidConfiguration_Succeeds()
+ [DataTestMethod]
+ [DataRow("test-client-secret", null, DisplayName = "Client secret alone preserves existing configurations")]
+ [DataRow("test-client-secret", "", DisplayName = "Client secret ignores empty managed identity credential")]
+ [DataRow("test-client-secret", " \t ", DisplayName = "Client secret ignores whitespace managed identity credential")]
+ [DataRow(null, "test-managed-identity-client-id", DisplayName = "Managed identity alone enables federation")]
+ [DataRow("", "test-managed-identity-client-id", DisplayName = "Federation ignores empty client secret")]
+ [DataRow(" \t ", "test-managed-identity-client-id", DisplayName = "Federation ignores whitespace client secret")]
+ public void ValidateUserDelegatedAuth_ValidConfiguration_Succeeds(string clientSecret, string managedIdentityClientId)
{
- // Arrange - Set environment variables for Azure AD credentials
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, "test-client-id");
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, "test-client-secret");
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, "test-tenant-id");
-
- try
+ WithUserDelegatedAuthEnvironment(() =>
{
DataSource dataSource = new(
DatabaseType: DatabaseType.MSSQL,
@@ -2585,18 +2584,15 @@ public void ValidateUserDelegatedAuth_ValidConfiguration_Succeeds()
Entities: new(new Dictionary()));
MockFileSystem fileSystem = new();
- RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator();
- // Act & Assert - validation should succeed without throwing
- configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
- }
- finally
- {
- // Clean up environment variables
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, null);
- }
+ foreach (bool isValidateOnly in new[] { false, true })
+ {
+ RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(isValidateOnly);
+ configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
+ Assert.AreEqual(0, configValidator.ConfigValidationExceptions.Count);
+ }
+ }, clientSecret: clientSecret, managedIdentityClientId: managedIdentityClientId,
+ azureClientId: "startup-managed-identity-client-id");
}
///
@@ -2660,15 +2656,13 @@ public void ValidateOnlyMode_ConflictAfterPartialAdd_ReportsActualConflictingEnt
[DataRow("", "test-tenant", "test-secret", DisplayName = "Empty DAB_OBO_CLIENT_ID")]
[DataRow("test-client", "", "test-secret", DisplayName = "Empty DAB_OBO_TENANT_ID")]
[DataRow("test-client", "test-tenant", "", DisplayName = "Empty DAB_OBO_CLIENT_SECRET")]
+ [DataRow(" \t ", "test-tenant", "test-secret", DisplayName = "Whitespace DAB_OBO_CLIENT_ID")]
+ [DataRow("test-client", " \t ", "test-secret", DisplayName = "Whitespace DAB_OBO_TENANT_ID")]
+ [DataRow("test-client", "test-tenant", " \t ", DisplayName = "Whitespace DAB_OBO_CLIENT_SECRET")]
public void ValidateUserDelegatedAuth_MissingEnvVars_ThrowsError(
string clientId, string tenantId, string clientSecret)
{
- // Arrange - Set environment variables (some may be null/empty to test validation)
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, clientId);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, tenantId);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, clientSecret);
-
- try
+ WithUserDelegatedAuthEnvironment(() =>
{
DataSource dataSource = new(
DatabaseType: DatabaseType.MSSQL,
@@ -2701,14 +2695,10 @@ public void ValidateUserDelegatedAuth_MissingEnvVars_ThrowsError(
() => configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object));
Assert.AreEqual(RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_CREDENTIALS_ERR_MSG, ex.Message);
- }
- finally
- {
- // Clean up environment variables
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, null);
- }
+ Assert.AreEqual(OBO_MISSING_CREDENTIALS_ERROR_MESSAGE, ex.Message);
+ Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }, clientId: clientId, tenantId: tenantId, clientSecret: clientSecret);
}
///
@@ -2761,18 +2751,12 @@ public void ValidateUserDelegatedAuth_Disabled_SkipsValidation()
}
///
- /// Test to validate that DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, and DAB_OBO_CLIENT_SECRET are required.
- /// OBO authentication uses MSAL ConfidentialClientApplication with client secret for token exchange.
+ /// Validates that the existing environment variables still enable OBO without opting into a new credential method.
///
[TestMethod]
public void ValidateUserDelegatedAuth_AllRequiredEnvVarsSet_PassesValidation()
{
- // Arrange - Set all required environment variables for OBO authentication
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, "test-client-id");
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, "test-tenant-id");
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, "test-client-secret");
-
- try
+ WithUserDelegatedAuthEnvironment(() =>
{
DataSource dataSource = new(
DatabaseType: DatabaseType.MSSQL,
@@ -2802,14 +2786,196 @@ public void ValidateUserDelegatedAuth_AllRequiredEnvVarsSet_PassesValidation()
// Act & Assert - validation should succeed and not throw when all env vars are set
configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
- }
- finally
+ });
+ }
+
+ ///
+ /// Missing or blank credentials fail both startup validation and validate-only mode without exposing their values.
+ ///
+ [DataTestMethod]
+ [DataRow("test-client-id", "test-tenant-id", null, null)]
+ [DataRow("test-client-id", "test-tenant-id", null, "")]
+ [DataRow("test-client-id", "test-tenant-id", null, " \t ")]
+ [DataRow("test-client-id", "test-tenant-id", "", null)]
+ [DataRow("test-client-id", "test-tenant-id", "", "")]
+ [DataRow("test-client-id", "test-tenant-id", "", " \t ")]
+ [DataRow("test-client-id", "test-tenant-id", " \t ", null)]
+ [DataRow("test-client-id", "test-tenant-id", " \t ", "")]
+ [DataRow("test-client-id", "test-tenant-id", " \t ", " \t ")]
+ [DataRow(null, "test-tenant-id", "test-client-secret", null)]
+ [DataRow("", "test-tenant-id", "test-client-secret", null)]
+ [DataRow(" \t ", "test-tenant-id", "test-client-secret", null)]
+ [DataRow("test-client-id", null, "test-client-secret", null)]
+ [DataRow("test-client-id", "", "test-client-secret", null)]
+ [DataRow("test-client-id", " \t ", "test-client-secret", null)]
+ [DataRow(null, "test-tenant-id", null, "test-managed-identity-client-id")]
+ [DataRow("", "test-tenant-id", null, "test-managed-identity-client-id")]
+ [DataRow(" \t ", "test-tenant-id", null, "test-managed-identity-client-id")]
+ [DataRow("test-client-id", null, null, "test-managed-identity-client-id")]
+ [DataRow("test-client-id", "", null, "test-managed-identity-client-id")]
+ [DataRow("test-client-id", " \t ", null, "test-managed-identity-client-id")]
+ public void ValidateUserDelegatedAuth_MissingCredentialEnvironment_FailsBothValidationModes(
+ string clientId,
+ string tenantId,
+ string clientSecret,
+ string managedIdentityClientId)
+ {
+ WithUserDelegatedAuthEnvironment(() =>
{
- // Clean up environment variables
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR, null);
- Environment.SetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR, null);
- }
+ AssertUserDelegatedAuthCredentialValidationError(OBO_MISSING_CREDENTIALS_ERROR_MESSAGE);
+ }, clientId: clientId, tenantId: tenantId, clientSecret: clientSecret,
+ managedIdentityClientId: managedIdentityClientId, azureClientId: "startup-managed-identity-client-id");
+ }
+
+ ///
+ /// Two configured credentials are always ambiguous, including when common identifiers are missing.
+ ///
+ [DataTestMethod]
+ [DataRow("test-client-id", "test-tenant-id")]
+ [DataRow(null, "test-tenant-id")]
+ [DataRow("test-client-id", null)]
+ [DataRow(null, null)]
+ [DataRow(" \t ", " \t ")]
+ public void ValidateUserDelegatedAuth_ConflictingCredentials_FailsBothValidationModes(
+ string clientId, string tenantId)
+ {
+ WithUserDelegatedAuthEnvironment(() =>
+ {
+ AssertUserDelegatedAuthCredentialValidationError(OBO_CREDENTIAL_CONFLICT_ERROR_MESSAGE);
+ }, clientId: clientId, tenantId: tenantId,
+ managedIdentityClientId: "test-managed-identity-client-id");
+ }
+
+ [DataTestMethod]
+ [DataRow(null, "test-client-secret", "test-managed-identity-client-id")]
+ [DataRow(false, "test-client-secret", "test-managed-identity-client-id")]
+ [DataRow(null, null, null)]
+ [DataRow(false, null, null)]
+ [DataRow(null, " \t ", " \t ")]
+ [DataRow(false, " \t ", " \t ")]
+ public void ValidateUserDelegatedAuth_NotEnabled_IgnoresInvalidCredentialEnvironment(
+ bool? enabled, string clientSecret, string managedIdentityClientId)
+ {
+ WithUserDelegatedAuthEnvironment(() =>
+ {
+ RuntimeConfig runtimeConfig = CreateUserDelegatedAuthRuntimeConfig(
+ databaseType: DatabaseType.PostgreSQL, enabled: enabled, databaseAudience: null, cacheEnabled: true);
+ MockFileSystem fileSystem = new();
+
+ foreach (bool isValidateOnly in new[] { false, true })
+ {
+ RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(isValidateOnly);
+ configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
+ Assert.AreEqual(0, configValidator.ConfigValidationExceptions.Count);
+ }
+ }, clientId: null, tenantId: null,
+ clientSecret: clientSecret, managedIdentityClientId: managedIdentityClientId);
+ }
+
+ [DataTestMethod]
+ [DataRow(DatabaseType.PostgreSQL, "https://database.windows.net/", RuntimeConfigValidator.USER_DELEGATED_AUTH_DATABASE_TYPE_ERR_MSG)]
+ [DataRow(DatabaseType.MySQL, "https://database.windows.net/", RuntimeConfigValidator.USER_DELEGATED_AUTH_DATABASE_TYPE_ERR_MSG)]
+ [DataRow(DatabaseType.DWSQL, "https://database.windows.net/", RuntimeConfigValidator.USER_DELEGATED_AUTH_DATABASE_TYPE_ERR_MSG)]
+ [DataRow(DatabaseType.CosmosDB_NoSQL, "https://database.windows.net/", RuntimeConfigValidator.USER_DELEGATED_AUTH_DATABASE_TYPE_ERR_MSG)]
+ [DataRow(DatabaseType.MSSQL, null, RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG)]
+ [DataRow(DatabaseType.MSSQL, "", RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG)]
+ [DataRow(DatabaseType.MSSQL, " \t ", RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG)]
+ public void ValidateUserDelegatedAuth_WorkloadIdentity_PreservesDataSourceRestrictions(
+ DatabaseType databaseType, string databaseAudience, string expectedMessage)
+ {
+ WithUserDelegatedAuthEnvironment(() =>
+ {
+ RuntimeConfig runtimeConfig = CreateUserDelegatedAuthRuntimeConfig(databaseType: databaseType, databaseAudience: databaseAudience);
+ MockFileSystem fileSystem = new();
+ RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator();
+
+ DataApiBuilderException exception = Assert.ThrowsException(
+ () => configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object));
+
+ AssertUserDelegatedAuthConfigError(exception, expectedMessage);
+ }, clientSecret: null, managedIdentityClientId: "test-managed-identity-client-id");
+ }
+
+ ///
+ /// An environment error must not escape validate-only mode or prevent later configuration errors from being recorded.
+ ///
+ [TestMethod]
+ public void ValidateUserDelegatedAuth_ValidateOnly_AccumulatesCredentialAndConfigurationErrors()
+ {
+ WithUserDelegatedAuthEnvironment(() =>
+ {
+ RuntimeConfig runtimeConfig = CreateUserDelegatedAuthRuntimeConfig(
+ databaseType: DatabaseType.PostgreSQL, databaseAudience: null, cacheEnabled: true);
+ MockFileSystem fileSystem = new();
+ RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(isValidateOnly: true);
+
+ configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
+
+ string[] expectedMessages =
+ {
+ RuntimeConfigValidator.USER_DELEGATED_AUTH_DATABASE_TYPE_ERR_MSG,
+ RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG,
+ OBO_CREDENTIAL_CONFLICT_ERROR_MESSAGE,
+ RuntimeConfigValidator.USER_DELEGATED_AUTH_CACHING_ERR_MSG
+ };
+ CollectionAssert.AreEqual(expectedMessages, configValidator.ConfigValidationExceptions.Select(exception => exception.Message).ToArray());
+ foreach (Exception exception in configValidator.ConfigValidationExceptions)
+ {
+ Assert.IsInstanceOfType(exception, typeof(DataApiBuilderException));
+ AssertUserDelegatedAuthConfigError((DataApiBuilderException)exception, exception.Message);
+ }
+ }, managedIdentityClientId: "test-managed-identity-client-id");
+ }
+
+ ///
+ /// Global errors are recorded once even when several data sources enable OBO, while each source's audience is validated.
+ ///
+ [DataTestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public void ValidateUserDelegatedAuth_ValidateOnly_RecordsGlobalErrorsOnceAcrossDataSources(bool cacheEnabled)
+ {
+ WithUserDelegatedAuthEnvironment(() =>
+ {
+ RuntimeConfig delegatedConfig = CreateUserDelegatedAuthRuntimeConfig(databaseAudience: null, cacheEnabled: cacheEnabled);
+ DataSource disabledDataSource = CreateUserDelegatedAuthRuntimeConfig(
+ databaseType: DatabaseType.PostgreSQL, enabled: false, databaseAudience: null).DataSource;
+ RuntimeConfig runtimeConfig = new(
+ Schema: "UnitTestSchema",
+ DataSource: disabledDataSource,
+ Runtime: delegatedConfig.Runtime,
+ Entities: delegatedConfig.Entities,
+ DefaultDataSourceName: "disabled",
+ DataSourceNameToDataSource: new()
+ {
+ ["disabled"] = disabledDataSource,
+ ["first-delegated"] = delegatedConfig.DataSource,
+ ["second-delegated"] = delegatedConfig.DataSource with { }
+ },
+ EntityNameToDataSourceName: new());
+ MockFileSystem fileSystem = new();
+ RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(isValidateOnly: true);
+
+ configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
+
+ List expectedMessages = new()
+ {
+ RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG,
+ RuntimeConfigValidator.USER_DELEGATED_AUTH_MISSING_AUDIENCE_ERR_MSG,
+ OBO_CREDENTIAL_CONFLICT_ERROR_MESSAGE
+ };
+ if (cacheEnabled)
+ {
+ expectedMessages.Add(RuntimeConfigValidator.USER_DELEGATED_AUTH_CACHING_ERR_MSG);
+ }
+
+ CollectionAssert.AreEquivalent(expectedMessages, configValidator.ConfigValidationExceptions.Select(exception => exception.Message).ToArray());
+ foreach (Exception exception in configValidator.ConfigValidationExceptions)
+ {
+ Assert.IsInstanceOfType(exception, typeof(DataApiBuilderException));
+ AssertUserDelegatedAuthConfigError((DataApiBuilderException)exception, exception.Message);
+ }
+ }, managedIdentityClientId: "test-managed-identity-client-id");
}
///
@@ -3772,12 +3938,135 @@ public void ValidateEmbeddingsOptions_EndpointDisabled_SkipsValidation()
configValidator.ValidateEmbeddingsOptions(runtimeConfig);
}
- private static RuntimeConfigValidator InitializeRuntimeConfigValidator()
+ private static RuntimeConfigValidator InitializeRuntimeConfigValidator(bool isValidateOnly = false)
{
MockFileSystem fileSystem = new();
FileSystemRuntimeConfigLoader loader = new(fileSystem);
RuntimeConfigProvider provider = new(loader);
- return new(provider, fileSystem, new Mock>().Object);
+ return new(provider, fileSystem, new Mock>().Object, isValidateOnly);
+ }
+
+ private static RuntimeConfig CreateUserDelegatedAuthRuntimeConfig(
+ DatabaseType databaseType = DatabaseType.MSSQL,
+ bool? enabled = true,
+ string databaseAudience = "https://database.windows.net/",
+ bool cacheEnabled = false)
+ {
+ DataSource dataSource = new(
+ DatabaseType: databaseType,
+ ConnectionString: databaseType == DatabaseType.PostgreSQL ? "Host=test;Database=test;" : "Server=test;Database=test;",
+ Options: databaseType == DatabaseType.CosmosDB_NoSQL
+ ? new()
+ {
+ [new HyphenatedNamingPolicy().ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.GraphQLSchema))] = "type Query { value: String }"
+ }
+ : null)
+ {
+ UserDelegatedAuth = enabled.HasValue
+ ? new(Enabled: enabled.Value, Provider: "EntraId", DatabaseAudience: databaseAudience)
+ : null
+ };
+
+ return new(
+ Schema: "UnitTestSchema",
+ DataSource: dataSource,
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: new(),
+ Host: new(Cors: null, Authentication: null),
+ Cache: new(Enabled: cacheEnabled)),
+ Entities: new(new Dictionary()));
+ }
+
+ private static void AssertUserDelegatedAuthCredentialValidationError(string expectedMessage)
+ {
+ RuntimeConfig runtimeConfig = CreateUserDelegatedAuthRuntimeConfig();
+ MockFileSystem fileSystem = new();
+
+ foreach (bool isValidateOnly in new[] { false, true })
+ {
+ RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(isValidateOnly);
+ DataApiBuilderException exception;
+
+ if (isValidateOnly)
+ {
+ configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object);
+ Assert.AreEqual(1, configValidator.ConfigValidationExceptions.Count);
+ Assert.IsInstanceOfType(configValidator.ConfigValidationExceptions[0], typeof(DataApiBuilderException));
+ exception = (DataApiBuilderException)configValidator.ConfigValidationExceptions[0];
+ }
+ else
+ {
+ exception = Assert.ThrowsException(
+ () => configValidator.ValidateDataSourceInConfig(runtimeConfig, fileSystem, new Mock().Object));
+ }
+
+ AssertUserDelegatedAuthConfigError(exception, expectedMessage);
+ }
+ }
+
+ private static void AssertUserDelegatedAuthConfigError(DataApiBuilderException exception, string expectedMessage)
+ {
+ Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode);
+
+ Assert.AreEqual(expectedMessage, exception.Message);
+
+ string exceptionDetails = exception.ToString();
+ foreach (string variableName in new[]
+ {
+ UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR,
+ UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR,
+ UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR,
+ UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR,
+ "AZURE_CLIENT_ID"
+ })
+ {
+ string value = Environment.GetEnvironmentVariable(variableName);
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ Assert.IsFalse(exceptionDetails.Contains(value, StringComparison.Ordinal),
+ "Configuration exceptions must not expose credential environment values.");
+ }
+ }
+ }
+
+ private static void WithUserDelegatedAuthEnvironment(
+ Action action,
+ string clientId = "test-client-id",
+ string tenantId = "test-tenant-id",
+ string clientSecret = "test-client-secret",
+ string managedIdentityClientId = null,
+ string azureClientId = null)
+ {
+ Dictionary environmentVariables = new()
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = clientId,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = tenantId,
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = clientSecret,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = managedIdentityClientId,
+ ["AZURE_CLIENT_ID"] = azureClientId
+ };
+ Dictionary originalValues = environmentVariables.Keys.ToDictionary(
+ name => name, name => Environment.GetEnvironmentVariable(name));
+
+ try
+ {
+ foreach (KeyValuePair variable in environmentVariables)
+ {
+ Environment.SetEnvironmentVariable(variable.Key, variable.Value);
+ }
+
+ action();
+ }
+ finally
+ {
+ foreach (KeyValuePair variable in originalValues)
+ {
+ Environment.SetEnvironmentVariable(variable.Key, variable.Value);
+ }
+ }
}
private static Entity GetSampleEntityUsingSourceAndRelationshipMap(
@@ -3881,4 +4170,3 @@ private static ForeignKeyDefinition CreateForeignKey(RelationShipPair pair)
}
}
}
-
diff --git a/src/Service.Tests/UnitTests/StartupTests.cs b/src/Service.Tests/UnitTests/StartupTests.cs
index b662a23c4c..a4cc32550f 100644
--- a/src/Service.Tests/UnitTests/StartupTests.cs
+++ b/src/Service.Tests/UnitTests/StartupTests.cs
@@ -3,7 +3,12 @@
using System;
using System.Collections.Generic;
+using System.Net;
using System.Threading.Tasks;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Resolvers;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using StackExchange.Redis;
@@ -11,8 +16,203 @@
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
{
[TestClass]
+ [DoNotParallelize]
public class StartupTests
{
+ private const string OBO_TEST_CLIENT_ID = "11111111-1111-1111-1111-111111111111";
+ private const string OBO_TEST_TENANT_ID = "22222222-2222-2222-2222-222222222222";
+ private const string OBO_TEST_MANAGED_IDENTITY_CLIENT_ID = "33333333-3333-3333-3333-333333333333";
+
+ [DataTestMethod]
+ [DataRow("test-client-secret", null, DisplayName = "Existing secret configuration")]
+ [DataRow("test-client-secret", "", DisplayName = "Secret with empty identity")]
+ [DataRow("test-client-secret", " \t", DisplayName = "Secret with whitespace identity")]
+ [DataRow(null, OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, DisplayName = "Federation without a client secret")]
+ [DataRow("", OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, DisplayName = "Federation with empty secret")]
+ [DataRow(" \t", OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, DisplayName = "Federation with whitespace secret")]
+ public void RegisterOboServices_ValidCredentials_RegistersSingletons(
+ string? clientSecret,
+ string? managedIdentityClientId)
+ {
+ WithOboEnvironment(clientSecret, managedIdentityClientId, () =>
+ {
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddFusionCache();
+
+ Startup.RegisterOboServices(services, CreateOboRuntimeConfig(enabled: true));
+ using ServiceProvider provider = services.BuildServiceProvider();
+
+ IMsalClientWrapper msalClient = provider.GetRequiredService();
+ IOboTokenProvider tokenProvider = provider.GetRequiredService();
+ Assert.IsInstanceOfType(msalClient);
+ Assert.IsInstanceOfType(tokenProvider);
+ Assert.AreSame(msalClient, provider.GetRequiredService());
+ Assert.AreSame(tokenProvider, provider.GetRequiredService());
+ });
+ }
+
+ [DataTestMethod]
+ [DataRow(null, null, DisplayName = "Neither credential")]
+ [DataRow(null, "", DisplayName = "Missing secret and empty identity")]
+ [DataRow(null, " \t", DisplayName = "Missing secret and whitespace identity")]
+ [DataRow("", null, DisplayName = "Empty secret and missing identity")]
+ [DataRow("", "", DisplayName = "Both credentials empty")]
+ [DataRow("", " \t", DisplayName = "Empty secret and whitespace identity")]
+ [DataRow(" \t", null, DisplayName = "Whitespace secret and missing identity")]
+ [DataRow(" \t", "", DisplayName = "Whitespace secret and empty identity")]
+ [DataRow(" \t", " \t", DisplayName = "Both credentials whitespace")]
+ [DataRow("test-client-secret", OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, DisplayName = "Conflicting credentials")]
+ public void RegisterOboServices_InvalidCredentials_FailsInsteadOfDisablingObo(
+ string? clientSecret,
+ string? managedIdentityClientId)
+ {
+ WithOboEnvironment(clientSecret, managedIdentityClientId, () =>
+ {
+ ServiceCollection services = new();
+ DataApiBuilderException exception = Assert.ThrowsException(
+ () => Startup.RegisterOboServices(services, CreateOboRuntimeConfig(enabled: true)));
+
+ Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode);
+ Assert.IsFalse(exception.Message.Contains("test-client-secret", StringComparison.Ordinal));
+ Assert.AreEqual(0, services.Count);
+ });
+ }
+
+ [DataTestMethod]
+ [DataRow("test-client-secret", null, UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR)]
+ [DataRow("test-client-secret", null, UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR)]
+ [DataRow(null, OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR)]
+ [DataRow(null, OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR)]
+ public void RegisterOboServices_MissingCommonCredentials_FailsStartup(
+ string? clientSecret,
+ string? managedIdentityClientId,
+ string variableName)
+ {
+ WithOboEnvironment(clientSecret, managedIdentityClientId, () =>
+ {
+ foreach (string? missingValue in new[] { null, "", " \t" })
+ {
+ Environment.SetEnvironmentVariable(variableName, missingValue);
+ ServiceCollection services = new();
+ DataApiBuilderException exception = Assert.ThrowsException(
+ () => Startup.RegisterOboServices(services, CreateOboRuntimeConfig(enabled: true)));
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode);
+ StringAssert.Contains(exception.Message, variableName);
+ Assert.AreEqual(0, services.Count);
+ }
+ });
+ }
+
+ [DataTestMethod]
+ [DataRow(null, null)]
+ [DataRow(" \t", " \t")]
+ [DataRow("test-client-secret", OBO_TEST_MANAGED_IDENTITY_CLIENT_ID)]
+ public void RegisterOboServices_OboDisabled_IgnoresCredentialSettings(
+ string? clientSecret,
+ string? managedIdentityClientId)
+ {
+ WithOboEnvironment(clientSecret, managedIdentityClientId, () =>
+ {
+ ServiceCollection services = new();
+ Startup.RegisterOboServices(services, runtimeConfig: null);
+ Startup.RegisterOboServices(services, CreateOboRuntimeConfig(enabled: null));
+ Startup.RegisterOboServices(services, CreateOboRuntimeConfig(enabled: false));
+
+ Assert.AreEqual(0, services.Count);
+ });
+ }
+
+ [TestMethod]
+ public void RegisterOboServices_EnabledSecondaryDataSource_RegistersProvider()
+ {
+ WithOboEnvironment(null, OBO_TEST_MANAGED_IDENTITY_CLIENT_ID, () =>
+ {
+ RuntimeConfig defaultConfig = CreateOboRuntimeConfig(enabled: false);
+ RuntimeConfig delegatedConfig = CreateOboRuntimeConfig(enabled: true);
+ RuntimeConfig runtimeConfig = new(
+ Schema: defaultConfig.Schema,
+ DataSource: defaultConfig.DataSource,
+ Runtime: defaultConfig.Runtime,
+ Entities: defaultConfig.Entities,
+ DefaultDataSourceName: "default",
+ DataSourceNameToDataSource: new()
+ {
+ ["default"] = defaultConfig.DataSource,
+ ["delegated"] = delegatedConfig.DataSource
+ },
+ EntityNameToDataSourceName: new());
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddFusionCache();
+ Startup.RegisterOboServices(services, runtimeConfig);
+ using ServiceProvider provider = services.BuildServiceProvider();
+
+ Assert.IsNotNull(provider.GetRequiredService());
+ });
+ }
+
+ private static RuntimeConfig CreateOboRuntimeConfig(bool? enabled)
+ {
+ DataSource dataSource = new(DatabaseType.MSSQL, "Server=localhost;Database=test;", Options: null)
+ {
+ UserDelegatedAuth = enabled.HasValue
+ ? new UserDelegatedAuthOptions(
+ Enabled: enabled.Value,
+ Provider: "EntraId",
+ DatabaseAudience: "https://database.windows.net")
+ : null
+ };
+ return new RuntimeConfig(
+ Schema: "UnitTestSchema",
+ DataSource: dataSource,
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: new(),
+ Host: new(Cors: null, Authentication: null),
+ Cache: new(Enabled: false)),
+ Entities: new(new Dictionary()));
+ }
+
+ private static void WithOboEnvironment(
+ string? clientSecret,
+ string? managedIdentityClientId,
+ Action action)
+ {
+ Dictionary settings = new()
+ {
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR] = OBO_TEST_CLIENT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR] = OBO_TEST_TENANT_ID,
+ [UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR] = clientSecret,
+ [UserDelegatedAuthOptions.DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR] = managedIdentityClientId
+ };
+ Dictionary originalValues = new();
+ foreach (string name in settings.Keys)
+ {
+ originalValues.Add(name, Environment.GetEnvironmentVariable(name));
+ }
+
+ try
+ {
+ foreach ((string name, string? value) in settings)
+ {
+ Environment.SetEnvironmentVariable(name, value);
+ }
+
+ action();
+ }
+ finally
+ {
+ foreach ((string name, string? value) in originalValues)
+ {
+ Environment.SetEnvironmentVariable(name, value);
+ }
+ }
+ }
+
[DataTestMethod]
[DataRow("localhost:6379", false, DisplayName = "Localhost endpoint without password should NOT use Entra auth.")]
[DataRow("127.0.0.1:6379", false, DisplayName = "IPv4 loopback without password should NOT use Entra auth.")]
diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs
index 5b6ffbb7d7..9b3bc991cd 100644
--- a/src/Service/Startup.cs
+++ b/src/Service/Startup.cs
@@ -61,7 +61,6 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using Microsoft.Identity.Client;
using NodaTime;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
@@ -285,37 +284,7 @@ public void ConfigureServices(IServiceCollection services)
// within these factories the various instances will be created based on the database type and datasourceName.
services.AddSingleton();
- // Register IOboTokenProvider only when user-delegated auth is configured.
- // This avoids registering a null singleton and supports hot-reload scenarios.
- // Requires environment variables: DAB_OBO_CLIENT_ID, DAB_OBO_TENANT_ID, DAB_OBO_CLIENT_SECRET
- //
- // Design note: A single IOboTokenProvider is registered using one Azure AD app registration.
- // Multiple databases with different database-audience values ARE supported - the audience
- // is passed to GetAccessTokenOnBehalfOfAsync() at query execution time, allowing the same
- // MSAL client to acquire tokens for different resource servers.
- if (IsOboConfigured())
- {
- // Register IMsalClientWrapper for dependency injection
- services.AddSingleton(serviceProvider =>
- {
- string? clientId = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR);
- string? tenantId = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR);
- string? clientSecret = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR);
-
- string authority = $"https://login.microsoftonline.com/{tenantId}";
-
- IConfidentialClientApplication msalClient = ConfidentialClientApplicationBuilder
- .Create(clientId)
- .WithAuthority(authority)
- .WithClientSecret(clientSecret)
- .Build();
-
- return new MsalClientWrapper(msalClient);
- });
-
- // Register OboSqlTokenProvider with dependencies from DI
- services.AddSingleton();
- }
+ RegisterOboServices(services, runtimeConfig);
services.AddSingleton();
@@ -1594,39 +1563,27 @@ internal static bool IsConfigurationRequestAuthorized(HttpContext context)
}
///
- /// Checks whether On-Behalf-Of (OBO) authentication is configured by verifying that
- /// the required environment variables are set and the config has user-delegated auth enabled.
+ /// Registers one OBO client for all enabled data sources, without acquiring tokens at startup.
///
- /// True if OBO is configured and ready to use; otherwise, false.
- private bool IsOboConfigured()
+ internal static void RegisterOboServices(IServiceCollection services, RuntimeConfig? runtimeConfig)
{
- // Check required environment variables first (fast path)
- string? clientId = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_ID_ENV_VAR);
- string? tenantId = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_TENANT_ID_ENV_VAR);
- string? clientSecret = Environment.GetEnvironmentVariable(UserDelegatedAuthOptions.DAB_OBO_CLIENT_SECRET_ENV_VAR);
-
- if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(clientSecret))
+ if (!IsOboConfigured(runtimeConfig))
{
- return false;
+ return;
}
- // Check if any data source has user-delegated auth enabled
- RuntimeConfig? config = _configProvider?.TryGetConfig(out RuntimeConfig? c) == true ? c : null;
- if (config is null)
- {
- return false;
- }
+ // Invalid credentials must fail startup, not silently disable an enabled OBO data source.
+ OboCredentialFactory.ValidateEnvironmentVariables();
- foreach (DataSource ds in config.ListAllDataSources())
- {
- if (ds.IsUserDelegatedAuthEnabled &&
- !string.IsNullOrEmpty(ds.UserDelegatedAuth!.DatabaseAudience))
- {
- return true;
- }
- }
+ services.AddSingleton(serviceProvider =>
+ new MsalClientWrapper(OboCredentialFactory.CreateClient(
+ serviceProvider.GetRequiredService>())));
+ services.AddSingleton();
+ }
- return false;
+ private static bool IsOboConfigured(RuntimeConfig? runtimeConfig)
+ {
+ return runtimeConfig?.ListAllDataSources().Any(dataSource => dataSource.IsUserDelegatedAuthEnabled) == true;
}
///