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; } ///