Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 29 additions & 15 deletions src/Config/ObjectModel/DataSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -170,22 +175,31 @@ public record UserDelegatedAuthOptions(
public const int DEFAULT_TOKEN_CACHE_DURATION_MINUTES = 45;

/// <summary>
/// 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.
/// </summary>
public const string DAB_OBO_CLIENT_ID_ENV_VAR = "DAB_OBO_CLIENT_ID";

/// <summary>
/// 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.
/// </summary>
public const string DAB_OBO_CLIENT_SECRET_ENV_VAR = "DAB_OBO_CLIENT_SECRET";

/// <summary>
/// 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.
/// </summary>
public const string DAB_OBO_TENANT_ID_ENV_VAR = "DAB_OBO_TENANT_ID";

/// <summary>
/// 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.
/// </summary>
public const string DAB_OBO_MANAGED_IDENTITY_CLIENT_ID_ENV_VAR = "DAB_OBO_MANAGED_IDENTITY_CLIENT_ID";
}
184 changes: 184 additions & 0 deletions src/Core/AuthenticationHelpers/OboCredentialFactory.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Validates and creates the application credential used for user-delegated authentication.
/// </summary>
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";

/// <summary>
/// Validates the environment variables for the selected OBO application credential.
/// Callers are responsible for checking whether user-delegated authentication is enabled.
/// </summary>
public static void ValidateEnvironmentVariables()
{
ValidateEnvironmentVariables(Environment.GetEnvironmentVariable);
}

/// <summary>
/// Creates a confidential client whose credential is independent of the incoming user assertion.
/// The returned client should be reused for subsequent OBO requests.
/// </summary>
public static IConfidentialClientApplication CreateClient(ILogger logger)
{
return CreateClient(logger, Environment.GetEnvironmentVariable);
}

internal static void ValidateEnvironmentVariables(Func<string, string?> environmentVariableReader)
{
_ = ReadOptions(environmentVariableReader);
}

internal static IConfidentialClientApplication CreateClient(
ILogger logger,
Func<string, string?> environmentVariableReader,
Func<string, TokenCredential>? 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<string> 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,
Comment thread
gmohit21 marked this conversation as resolved.
subStatusCode: DataApiBuilderException.SubStatusCodes.OboAuthenticationFailure);
}

private static OboCredentialOptions ReadOptions(Func<string, string?> 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;
}
}
}
}
45 changes: 24 additions & 21 deletions src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -129,16 +129,18 @@ public void ValidateDataSourceInConfig(

/// <summary>
/// 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.
/// </summary>
/// <param name="runtimeConfig">Runtime configuration.</param>
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
Expand All @@ -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;
}
}
}
Expand Down
Loading
Loading