diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs
index b368227a75..8ab024ae0b 100644
--- a/src/Cli.Tests/ConfigureOptionsTests.cs
+++ b/src/Cli.Tests/ConfigureOptionsTests.cs
@@ -1094,5 +1094,186 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig)
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config));
Assert.IsNotNull(config.Runtime);
}
+
+ ///
+ /// Tests adding user-delegated-auth.enabled to a config that doesn't have user-delegated-auth configured.
+ /// This method verifies that the user-delegated-auth.enabled property can be set to true for MSSQL database.
+ /// Command: dab configure --data-source.user-delegated-auth.enabled true
+ ///
+ [TestMethod]
+ public void TestAddUserDelegatedAuthEnabled()
+ {
+ // Arrange
+ SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
+
+ ConfigureOptions options = new(
+ dataSourceUserDelegatedAuthEnabled: true,
+ config: TEST_RUNTIME_CONFIG_FILE
+ );
+
+ // Act
+ bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+ string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+ Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
+ Assert.IsNotNull(config.DataSource);
+ Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
+ Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
+ }
+
+ ///
+ /// Tests adding user-delegated-auth.database-audience to a config that doesn't have user-delegated-auth configured.
+ /// This method verifies that the database-audience can be set for user-delegated authentication.
+ /// Command: dab configure --data-source.user-delegated-auth.database-audience "https://database.windows.net"
+ ///
+ [TestMethod]
+ public void TestAddUserDelegatedAuthDatabaseAudience()
+ {
+ // Arrange
+ SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
+ string audienceValue = "https://database.windows.net";
+
+ ConfigureOptions options = new(
+ dataSourceUserDelegatedAuthDatabaseAudience: audienceValue,
+ config: TEST_RUNTIME_CONFIG_FILE
+ );
+
+ // Act
+ bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+ string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+ Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
+ Assert.IsNotNull(config.DataSource);
+ Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
+ Assert.AreEqual(audienceValue, config.DataSource.UserDelegatedAuth.DatabaseAudience);
+ }
+
+ ///
+ /// Tests adding both user-delegated-auth.enabled and database-audience in a single command.
+ /// This method verifies that both properties can be set together.
+ /// Command: dab configure --data-source.user-delegated-auth.enabled true --data-source.user-delegated-auth.database-audience "https://database.windows.net"
+ ///
+ [DataTestMethod]
+ [DataRow("https://database.windows.net", DisplayName = "Azure SQL Database (public cloud)")]
+ [DataRow("https://database.usgovcloudapi.net", DisplayName = "Azure Government Cloud")]
+ [DataRow("https://database.chinacloudapi.cn", DisplayName = "Azure China Cloud")]
+ [DataRow("https://myinstance.abc123.database.windows.net", DisplayName = "Azure SQL Managed Instance")]
+ public void TestAddUserDelegatedAuthEnabledAndDatabaseAudience(string audienceValue)
+ {
+ // Arrange
+ SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
+
+ ConfigureOptions options = new(
+ dataSourceUserDelegatedAuthEnabled: true,
+ dataSourceUserDelegatedAuthDatabaseAudience: audienceValue,
+ config: TEST_RUNTIME_CONFIG_FILE
+ );
+
+ // Act
+ bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+ string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+ Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
+ Assert.IsNotNull(config.DataSource);
+ Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
+ Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
+ Assert.AreEqual(audienceValue, config.DataSource.UserDelegatedAuth.DatabaseAudience);
+ }
+
+ ///
+ /// Tests that enabling user-delegated-auth on a non-MSSQL database fails.
+ /// This method verifies that user-delegated-auth is only allowed for MSSQL database type.
+ /// Command: dab configure --data-source.database-type postgresql --data-source.user-delegated-auth.enabled true
+ ///
+ [DataTestMethod]
+ [DataRow("postgresql", DisplayName = "Fail when enabling user-delegated-auth on PostgreSQL")]
+ [DataRow("mysql", DisplayName = "Fail when enabling user-delegated-auth on MySQL")]
+ [DataRow("cosmosdb_nosql", DisplayName = "Fail when enabling user-delegated-auth on CosmosDB")]
+ public void TestFailureWhenEnablingUserDelegatedAuthOnNonMSSQLDatabase(string dbType)
+ {
+ // Arrange
+ SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
+
+ ConfigureOptions options = new(
+ dataSourceDatabaseType: dbType,
+ dataSourceUserDelegatedAuthEnabled: true,
+ config: TEST_RUNTIME_CONFIG_FILE
+ );
+
+ // Act
+ bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsFalse(isSuccess);
+ }
+
+ ///
+ /// Tests updating existing user-delegated-auth configuration by changing the database-audience.
+ /// This method verifies that the database-audience can be updated while preserving the enabled setting.
+ ///
+ [TestMethod]
+ public void TestUpdateUserDelegatedAuthDatabaseAudience()
+ {
+ // Arrange - Config with existing user-delegated-auth section
+ string configWithUserDelegatedAuth = @"
+ {
+ ""$schema"": ""test"",
+ ""data-source"": {
+ ""database-type"": ""mssql"",
+ ""connection-string"": ""testconnectionstring"",
+ ""user-delegated-auth"": {
+ ""enabled"": true,
+ ""database-audience"": ""https://database.windows.net""
+ }
+ },
+ ""runtime"": {
+ ""rest"": {
+ ""enabled"": true,
+ ""path"": ""/api""
+ },
+ ""graphql"": {
+ ""enabled"": true,
+ ""path"": ""/graphql"",
+ ""allow-introspection"": true
+ },
+ ""host"": {
+ ""mode"": ""development"",
+ ""cors"": {
+ ""origins"": [],
+ ""allow-credentials"": false
+ },
+ ""authentication"": {
+ ""provider"": ""StaticWebApps""
+ }
+ }
+ },
+ ""entities"": {}
+ }";
+ SetupFileSystemWithInitialConfig(configWithUserDelegatedAuth);
+
+ string newAudience = "https://database.usgovcloudapi.net";
+ ConfigureOptions options = new(
+ dataSourceUserDelegatedAuthDatabaseAudience: newAudience,
+ config: TEST_RUNTIME_CONFIG_FILE
+ );
+
+ // Act
+ bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsTrue(isSuccess);
+ string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
+ Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
+ Assert.IsNotNull(config.DataSource);
+ Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
+ Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
+ Assert.AreEqual(newAudience, config.DataSource.UserDelegatedAuth.DatabaseAudience);
+ }
}
}
diff --git a/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs
new file mode 100644
index 0000000000..7a463a845c
--- /dev/null
+++ b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Cli.Tests
+{
+ [TestClass]
+ public class UserDelegatedAuthRuntimeParsingTests
+ {
+ [TestMethod]
+ public void TestRuntimeCanParseUserDelegatedAuthConfig()
+ {
+ // Arrange
+ string configJson = @"{
+ ""$schema"": ""test"",
+ ""data-source"": {
+ ""database-type"": ""mssql"",
+ ""connection-string"": ""testconnectionstring"",
+ ""user-delegated-auth"": {
+ ""enabled"": true,
+ ""database-audience"": ""https://database.windows.net""
+ }
+ },
+ ""runtime"": {
+ ""rest"": {
+ ""enabled"": true,
+ ""path"": ""/api""
+ },
+ ""graphql"": {
+ ""enabled"": true,
+ ""path"": ""/graphql"",
+ ""allow-introspection"": true
+ },
+ ""host"": {
+ ""mode"": ""development"",
+ ""cors"": {
+ ""origins"": [],
+ ""allow-credentials"": false
+ },
+ ""authentication"": {
+ ""provider"": ""StaticWebApps""
+ }
+ }
+ },
+ ""entities"": {}
+ }";
+
+ // Act
+ bool success = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config);
+
+ // Assert
+ Assert.IsTrue(success);
+ Assert.IsNotNull(config);
+ Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
+ Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
+ Assert.AreEqual("https://database.windows.net", config.DataSource.UserDelegatedAuth.DatabaseAudience);
+ Assert.AreEqual(50, config.DataSource.UserDelegatedAuth.EffectiveTokenCacheDurationMinutes);
+ Assert.IsTrue(config.DataSource.UserDelegatedAuth.EffectiveDisableConnectionPooling);
+ }
+
+ [TestMethod]
+ public void TestRuntimeCanParseConfigWithoutUserDelegatedAuth()
+ {
+ // Arrange
+ string configJson = @"{
+ ""$schema"": ""test"",
+ ""data-source"": {
+ ""database-type"": ""mssql"",
+ ""connection-string"": ""testconnectionstring""
+ },
+ ""runtime"": {
+ ""rest"": {
+ ""enabled"": true,
+ ""path"": ""/api""
+ },
+ ""graphql"": {
+ ""enabled"": true,
+ ""path"": ""/graphql"",
+ ""allow-introspection"": true
+ },
+ ""host"": {
+ ""mode"": ""development"",
+ ""cors"": {
+ ""origins"": [],
+ ""allow-credentials"": false
+ },
+ ""authentication"": {
+ ""provider"": ""StaticWebApps""
+ }
+ }
+ },
+ ""entities"": {}
+ }";
+
+ // Act
+ bool success = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config);
+
+ // Assert
+ Assert.IsTrue(success);
+ Assert.IsNotNull(config);
+ Assert.IsNull(config.DataSource.UserDelegatedAuth);
+ }
+ }
+}
diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs
index 14234d24d7..069a2d3b6a 100644
--- a/src/Cli/Commands/ConfigureOptions.cs
+++ b/src/Cli/Commands/ConfigureOptions.cs
@@ -29,6 +29,8 @@ public ConfigureOptions(
string? dataSourceOptionsSchema = null,
bool? dataSourceOptionsSetSessionContext = null,
string? dataSourceHealthName = null,
+ bool? dataSourceUserDelegatedAuthEnabled = null,
+ string? dataSourceUserDelegatedAuthDatabaseAudience = null,
int? depthLimit = null,
bool? runtimeGraphQLEnabled = null,
string? runtimeGraphQLPath = null,
@@ -84,6 +86,8 @@ public ConfigureOptions(
DataSourceOptionsSchema = dataSourceOptionsSchema;
DataSourceOptionsSetSessionContext = dataSourceOptionsSetSessionContext;
DataSourceHealthName = dataSourceHealthName;
+ DataSourceUserDelegatedAuthEnabled = dataSourceUserDelegatedAuthEnabled;
+ DataSourceUserDelegatedAuthDatabaseAudience = dataSourceUserDelegatedAuthDatabaseAudience;
// GraphQL
DepthLimit = depthLimit;
RuntimeGraphQLEnabled = runtimeGraphQLEnabled;
@@ -160,6 +164,12 @@ public ConfigureOptions(
[Option("data-source.health.name", Required = false, HelpText = "Identifier for data source in health check report.")]
public string? DataSourceHealthName { get; }
+ [Option("data-source.user-delegated-auth.enabled", Required = false, HelpText = "Enable user-delegated authentication (OBO) for Azure SQL. Default: false (boolean).")]
+ public bool? DataSourceUserDelegatedAuthEnabled { get; }
+
+ [Option("data-source.user-delegated-auth.database-audience", Required = false, HelpText = "Azure SQL resource identifier for token acquisition (e.g., https://database.windows.net).")]
+ public string? DataSourceUserDelegatedAuthDatabaseAudience { get; }
+
[Option("runtime.graphql.depth-limit", Required = false, HelpText = "Max allowed depth of the nested query. Allowed values: (0,2147483647] inclusive. Default is infinity. Use -1 to remove limit.")]
public int? DepthLimit { get; }
diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs
index 9a3401a55a..7e5b6c400e 100644
--- a/src/Cli/ConfigGenerator.cs
+++ b/src/Cli/ConfigGenerator.cs
@@ -643,6 +643,7 @@ private static bool TryUpdateConfiguredDataSourceOptions(
DatabaseType dbType = runtimeConfig.DataSource.DatabaseType;
string dataSourceConnectionString = runtimeConfig.DataSource.ConnectionString;
DatasourceHealthCheckConfig? datasourceHealthCheckConfig = runtimeConfig.DataSource.Health;
+ UserDelegatedAuthConfig? userDelegatedAuthConfig = runtimeConfig.DataSource.UserDelegatedAuth;
if (options.DataSourceDatabaseType is not null)
{
@@ -714,8 +715,36 @@ private static bool TryUpdateConfiguredDataSourceOptions(
}
}
+ // Handle user-delegated-auth options
+ if (options.DataSourceUserDelegatedAuthEnabled is not null
+ || options.DataSourceUserDelegatedAuthDatabaseAudience is not null)
+ {
+ // Determine the enabled state: use new value if provided, otherwise preserve existing
+ bool enabled = options.DataSourceUserDelegatedAuthEnabled
+ ?? userDelegatedAuthConfig?.Enabled
+ ?? false;
+
+ // Validate that user-delegated-auth is only used with MSSQL when enabled=true
+ if (enabled && !DatabaseType.MSSQL.Equals(dbType))
+ {
+ _logger.LogError("user-delegated-auth is only supported for database-type 'mssql'.");
+ return false;
+ }
+
+ // Get database-audience: use new value if provided, otherwise preserve existing
+ string? databaseAudience = options.DataSourceUserDelegatedAuthDatabaseAudience
+ ?? userDelegatedAuthConfig?.DatabaseAudience;
+
+ // Create or update user-delegated-auth config
+ userDelegatedAuthConfig = new UserDelegatedAuthConfig(
+ Enabled: enabled,
+ DatabaseAudience: databaseAudience,
+ DisableConnectionPooling: userDelegatedAuthConfig?.DisableConnectionPooling,
+ TokenCacheDurationMinutes: userDelegatedAuthConfig?.TokenCacheDurationMinutes);
+ }
+
dbOptions = EnumerableUtilities.IsNullOrEmpty(dbOptions) ? null : dbOptions;
- DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig);
+ DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig, userDelegatedAuthConfig);
runtimeConfig = runtimeConfig with { DataSource = dataSource };
return runtimeConfig != null;
diff --git a/src/Config/Converters/DataSourceConverterFactory.cs b/src/Config/Converters/DataSourceConverterFactory.cs
index 1788ebf2b4..3f697061b2 100644
--- a/src/Config/Converters/DataSourceConverterFactory.cs
+++ b/src/Config/Converters/DataSourceConverterFactory.cs
@@ -51,12 +51,13 @@ public DataSourceConverter(DeserializationVariableReplacementSettings? replaceme
string connectionString = string.Empty;
DatasourceHealthCheckConfig? health = null;
Dictionary? datasourceOptions = null;
+ UserDelegatedAuthConfig? userDelegatedAuth = null;
while (reader.Read())
{
if (reader.TokenType is JsonTokenType.EndObject)
{
- return new DataSource(databaseType, connectionString, datasourceOptions, health);
+ return new DataSource(databaseType, connectionString, datasourceOptions, health, userDelegatedAuth);
}
if (reader.TokenType is JsonTokenType.PropertyName)
@@ -91,6 +92,20 @@ public DataSourceConverter(DeserializationVariableReplacementSettings? replaceme
}
}
+ break;
+ case "user-delegated-auth":
+ if (reader.TokenType is not JsonTokenType.Null)
+ {
+ try
+ {
+ userDelegatedAuth = JsonSerializer.Deserialize(ref reader, options);
+ }
+ catch (Exception e)
+ {
+ throw new JsonException($"Error while deserializing DataSource user-delegated-auth: {e.Message}");
+ }
+ }
+
break;
case "options":
if (reader.TokenType is not JsonTokenType.Null)
diff --git a/src/Config/ObjectModel/DataSource.cs b/src/Config/ObjectModel/DataSource.cs
index d1a2456ef9..769e109a43 100644
--- a/src/Config/ObjectModel/DataSource.cs
+++ b/src/Config/ObjectModel/DataSource.cs
@@ -14,11 +14,13 @@ namespace Azure.DataApiBuilder.Config.ObjectModel;
/// Connection string to access the database.
/// Custom options for the specific database. If there are no options, this could be null.
/// Health check configuration for the datasource.
+/// User-delegated authentication configuration (OBO). Optional.
public record DataSource(
DatabaseType DatabaseType,
string ConnectionString,
Dictionary? Options = null,
- DatasourceHealthCheckConfig? Health = null)
+ DatasourceHealthCheckConfig? Health = null,
+ UserDelegatedAuthConfig? UserDelegatedAuth = null)
{
[JsonIgnore]
public bool IsDatasourceHealthEnabled =>
diff --git a/src/Config/ObjectModel/UserDelegatedAuthConfig.cs b/src/Config/ObjectModel/UserDelegatedAuthConfig.cs
new file mode 100644
index 0000000000..ee086a4053
--- /dev/null
+++ b/src/Config/ObjectModel/UserDelegatedAuthConfig.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Azure.DataApiBuilder.Config.ObjectModel;
+
+///
+/// Configuration for user-delegated authentication (OBO - On-Behalf-Of).
+/// Enables per-user Entra ID access token authentication to Azure SQL.
+///
+/// Whether user-delegated authentication is enabled.
+/// The Azure SQL resource identifier for token acquisition.
+/// Explicitly control connection pooling behavior. Default: true (disabled) for safety. Connection pooling is disabled by default in OBO scenarios to prevent token reuse across different user contexts.
+/// In-memory cache duration for OBO tokens per user. Default: 50 minutes.
+public record UserDelegatedAuthConfig(
+ bool Enabled = false,
+ string? DatabaseAudience = null,
+ bool? DisableConnectionPooling = null,
+ int? TokenCacheDurationMinutes = null)
+{
+ ///
+ /// Default value for token cache duration in minutes.
+ /// Must be less than typical token lifetime (60 min).
+ ///
+ public const int DEFAULT_TOKEN_CACHE_DURATION_MINUTES = 50;
+
+ ///
+ /// Default value for connection pooling (disabled for safety in MVP).
+ ///
+ public const bool DEFAULT_DISABLE_CONNECTION_POOLING = true;
+
+ ///
+ /// Minimum allowed token cache duration in minutes.
+ ///
+ public const int MIN_TOKEN_CACHE_DURATION_MINUTES = 1;
+
+ ///
+ /// Maximum allowed token cache duration in minutes.
+ /// Must be less than typical token lifetime (60 min).
+ ///
+ public const int MAX_TOKEN_CACHE_DURATION_MINUTES = 59;
+
+ ///
+ /// Gets the effective token cache duration value.
+ /// Returns the configured value or the default if not specified.
+ ///
+ [JsonIgnore]
+ public int EffectiveTokenCacheDurationMinutes =>
+ TokenCacheDurationMinutes ?? DEFAULT_TOKEN_CACHE_DURATION_MINUTES;
+
+ ///
+ /// Gets the effective connection pooling setting.
+ /// Returns the configured value or the default if not specified.
+ ///
+ [JsonIgnore]
+ public bool EffectiveDisableConnectionPooling =>
+ DisableConnectionPooling ?? DEFAULT_DISABLE_CONNECTION_POOLING;
+}