diff --git a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs
index 44dac71360..b2dede0cf6 100644
--- a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs
+++ b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs
@@ -204,10 +204,13 @@ private static string ResolveConfiguredAuthNScheme(string? configuredProviderNam
}
else
{
- // Changing this value is a breaking change because non-out of box
- // authentication provider names supplied in dab-config.json indicate
- // that JWT bearer authentication should be used.
- return GenericOAuthDefaults.AUTHENTICATIONSCHEME;
+ // Every non-EasyAuth/Simulator/Unauthenticated provider (AzureAD, EntraID, and any
+ // custom OAuth/JWT provider such as "Custom") is authenticated via JWT bearer. The JWT
+ // handler is always registered under JwtBearerDefaults.AuthenticationScheme ("Bearer")
+ // in Startup's ConfigureAuthentication/ConfigureAuthenticationV2, so the resolved scheme
+ // must match that registration - otherwise AuthenticateAsync throws
+ // "No authentication handler is registered for the scheme ...".
+ return JwtBearerDefaults.AuthenticationScheme;
}
}
}
diff --git a/src/Core/AuthenticationHelpers/GenericOAuthDefaults.cs b/src/Core/AuthenticationHelpers/GenericOAuthDefaults.cs
index 0faf2b3085..56f66eafd6 100644
--- a/src/Core/AuthenticationHelpers/GenericOAuthDefaults.cs
+++ b/src/Core/AuthenticationHelpers/GenericOAuthDefaults.cs
@@ -4,9 +4,19 @@
namespace Azure.DataApiBuilder.Core.AuthenticationHelpers;
///
-/// Authentication Scheme name for generic OAuth providers.
+/// Authentication scheme name previously used for generic OAuth providers.
///
+///
+/// Retained only for backward binary/source compatibility of the public
+/// Microsoft.DataApiBuilder.Core package surface. DAB no longer uses this scheme:
+/// custom OAuth/JWT providers are authenticated with the registered
+///
+/// ("Bearer") scheme. The "OAuthAuthentication" scheme was never registered as an authentication
+/// handler, so referencing it results in a failed authentication.
+///
+[System.Obsolete("Unused and unsupported. Custom OAuth/JWT providers resolve to JwtBearerDefaults.AuthenticationScheme (\"Bearer\"). The \"OAuthAuthentication\" scheme is never registered.")]
public class GenericOAuthDefaults
{
+ [System.Obsolete("Unused and unsupported. Custom OAuth/JWT providers resolve to JwtBearerDefaults.AuthenticationScheme (\"Bearer\"). The \"OAuthAuthentication\" scheme is never registered.")]
public const string AUTHENTICATIONSCHEME = "OAuthAuthentication";
}
diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs
index f567251771..a17f8bc952 100644
--- a/src/Core/Resolvers/SqlQueryEngine.cs
+++ b/src/Core/Resolvers/SqlQueryEngine.cs
@@ -443,6 +443,14 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad
private static JsonDocument? ParseResultIntoJsonDocument(JsonElement? result)
{
+ // An empty result set surfaces as a default (JsonValueKind.Undefined) JsonElement with no
+ // backing document; serializing it throws InvalidOperationException. Return null to match the
+ // non-cached path, which renders an empty payload (e.g. {"value":[]}).
+ if (result is { ValueKind: JsonValueKind.Undefined })
+ {
+ return null;
+ }
+
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
return JsonDocument.Parse(jsonBytes);
}
diff --git a/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs
index 12a0b2ebd0..ab15d2d879 100644
--- a/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs
+++ b/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs
@@ -87,6 +87,46 @@ await SendRequestAndGetHttpContextState(
ignoreCase: true);
}
+ ///
+ /// Regression test for https://github.com/Azure/data-api-builder/issues/3541
+ /// A JWT-configured provider whose name is not an out-of-box provider (e.g. "Custom") must
+ /// resolve to the same "Bearer" scheme the JWT handler is registered under. Previously this
+ /// resolved to the unregistered "OAuthAuthentication" scheme, causing AuthenticateAsync to
+ /// throw "No authentication handler is registered for the scheme 'OAuthAuthentication'".
+ ///
+ [DataTestMethod]
+ [DataRow("Custom", DisplayName = "Custom JWT provider authenticates via the Bearer scheme")]
+ [DataRow("AzureAD", DisplayName = "AzureAD JWT provider authenticates via the Bearer scheme")]
+ [DataRow("EntraID", DisplayName = "EntraID JWT provider authenticates via the Bearer scheme")]
+ [TestMethod]
+ public async Task TestValidToken_JwtConfiguredProviders(string provider)
+ {
+ RsaSecurityKey key = new(RSA.Create(2048));
+ string token = CreateJwt(
+ audience: AUDIENCE,
+ issuer: LOCAL_ISSUER,
+ notBefore: DateTime.UtcNow.AddDays(-1),
+ expirationTime: DateTime.UtcNow.AddDays(1),
+ signingKey: key
+ );
+
+ HttpContext postMiddlewareContext =
+ await SendRequestAndGetHttpContextState(
+ key,
+ token,
+ clientRoleHeader: null,
+ provider: provider);
+
+ Assert.IsTrue(postMiddlewareContext.User.Identity.IsAuthenticated);
+ Assert.AreEqual(
+ expected: (int)HttpStatusCode.OK,
+ actual: postMiddlewareContext.Response.StatusCode);
+ Assert.AreEqual(
+ expected: AuthorizationType.Authenticated.ToString(),
+ actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER],
+ ignoreCase: true);
+ }
+
///
/// Test to validate that the user request is treated with anonymous role when
/// the jwt token is missing.
@@ -302,15 +342,17 @@ public async Task TestInvalidToken_NoSignature()
/// and configures Authentication options with passed in SecurityKey
///
///
+ /// Runtime configured identity provider name (e.g. "AzureAD" or a
+ /// custom OAuth/JWT provider such as "Custom"). All resolve to JWT bearer authentication.
/// IHost
- private static async Task CreateWebHostCustomIssuer(SecurityKey key)
+ private static async Task CreateWebHostCustomIssuer(SecurityKey key, string provider = "AzureAD")
{
// Setup RuntimeConfigProvider object for the pipeline.
MockFileSystem fileSystem = new();
FileSystemRuntimeConfigLoader fileSystemRuntimeConfigLoader = new(new MockFileSystem());
AuthenticationOptions authOptions = new()
{
- Provider = "AzureAD"
+ Provider = provider
};
RuntimeConfig runtimeConfig = RuntimeConfigAuthHelper.CreateTestConfigWithAuthNProvider(authOptions);
@@ -384,9 +426,10 @@ private static async Task CreateWebHostCustomIssuer(SecurityKey key)
private static async Task SendRequestAndGetHttpContextState(
SecurityKey key,
string token,
- string clientRoleHeader = null)
+ string clientRoleHeader = null,
+ string provider = "AzureAD")
{
- using IHost host = await CreateWebHostCustomIssuer(key);
+ using IHost host = await CreateWebHostCustomIssuer(key, provider);
TestServer server = host.GetTestServer();
return await server.SendAsync(context =>
diff --git a/src/Service.Tests/UnitTests/SqlQueryEngineHelperTests.cs b/src/Service.Tests/UnitTests/SqlQueryEngineHelperTests.cs
new file mode 100644
index 0000000000..cf3471906e
--- /dev/null
+++ b/src/Service.Tests/UnitTests/SqlQueryEngineHelperTests.cs
@@ -0,0 +1,209 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Auth;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Core.Models;
+using Azure.DataApiBuilder.Core.Resolvers;
+using Azure.DataApiBuilder.Core.Resolvers.Factories;
+using Azure.DataApiBuilder.Core.Services.Cache;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ [TestClass]
+ public class SqlQueryEngineHelperTests
+ {
+ private const string DATA_SOURCE_NAME = "default";
+ private const string ENTITY_NAME = "Book";
+
+ [DataTestMethod]
+ [DataRow("{\"value\":1}", true)]
+ [DataRow(null, false)]
+ public void ParseResultIntoJsonDocument_HandlesValuesAndNull(string? json, bool hasObject)
+ {
+ JsonElement? element = json is null ? null : JsonDocument.Parse(json).RootElement.Clone();
+ MethodInfo method = typeof(SqlQueryEngine).GetMethod(
+ "ParseResultIntoJsonDocument",
+ BindingFlags.Static | BindingFlags.NonPublic)!;
+
+ using JsonDocument result = (JsonDocument)method.Invoke(null, new object?[] { element })!;
+
+ Assert.AreEqual(hasObject ? JsonValueKind.Object : JsonValueKind.Null, result.RootElement.ValueKind);
+ }
+
+ ///
+ /// ParseResultIntoJsonDocument must return null for an undefined JsonElement from an empty cached read
+ /// to avoid a serialization exception.
+ ///
+ [TestMethod]
+ public void ParseResultIntoJsonDocument_UndefinedElement_ReturnsNull()
+ {
+ JsonElement? undefined = default(JsonElement);
+ MethodInfo method = typeof(SqlQueryEngine).GetMethod(
+ "ParseResultIntoJsonDocument",
+ BindingFlags.Static | BindingFlags.NonPublic)!;
+
+ JsonDocument? result = (JsonDocument?)method.Invoke(null, new object?[] { undefined });
+
+ Assert.IsNull(result);
+ }
+
+ ///
+ /// Verifies stored-procedure execution returns the first result object and maps empty or absent result arrays to null.
+ ///
+ [DataTestMethod]
+ [DataRow("[{\"id\":1}]", true, DisplayName = "Populated result returns a document")]
+ [DataRow("[]", false, DisplayName = "Empty result returns null")]
+ [DataRow(null, false, DisplayName = "Absent result returns null")]
+ public async Task ExecuteStoredProcedureCore_HandlesResultShapes(string? json, bool expectsDocument)
+ {
+ JsonArray? resultArray = json is null ? null : JsonNode.Parse(json)!.AsArray();
+ (SqlQueryEngine engine, Mock executor) = CreateEngine();
+ executor.Setup(x => x.ExecuteQueryAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny?, Task>>(),
+ DATA_SOURCE_NAME,
+ It.IsAny(),
+ It.IsAny?>()))
+ .ReturnsAsync(resultArray!);
+ SqlExecuteStructure structure = CreateUninitializedStructure();
+ MethodInfo method = typeof(SqlQueryEngine).GetMethod(
+ "ExecuteAsync",
+ BindingFlags.Instance | BindingFlags.NonPublic,
+ binder: null,
+ types: new[] { typeof(SqlExecuteStructure), typeof(string) },
+ modifiers: null)!;
+
+ using JsonDocument? result = await (Task)method.Invoke(
+ engine,
+ new object[] { structure, DATA_SOURCE_NAME })!;
+
+ Assert.AreEqual(expectsDocument, result is not null);
+ }
+
+ ///
+ /// Verifies list execution passes through either the executor's document list or its null result unchanged.
+ ///
+ [DataTestMethod]
+ [DataRow(true, DisplayName = "Executor returns a document list")]
+ [DataRow(false, DisplayName = "Executor returns null")]
+ public async Task ExecuteListCore_ReturnsExecutorResult(bool returnList)
+ {
+ (SqlQueryEngine engine, Mock executor) = CreateEngine();
+ List? expected = returnList
+ ? new List { JsonDocument.Parse("{\"id\":1}") }
+ : null;
+ executor.Setup(x => x.ExecuteQueryAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny?, Task>>>(),
+ DATA_SOURCE_NAME,
+ It.IsAny(),
+ It.IsAny?>()))
+ .ReturnsAsync(expected!);
+ SqlQueryStructure structure = CreateUninitializedStructure();
+ MethodInfo method = typeof(SqlQueryEngine).GetMethod(
+ "ExecuteListAsync",
+ BindingFlags.Instance | BindingFlags.NonPublic,
+ binder: null,
+ types: new[] { typeof(SqlQueryStructure), typeof(string) },
+ modifiers: null)!;
+
+ List? result = await (Task?>)method.Invoke(
+ engine,
+ new object[] { structure, DATA_SOURCE_NAME })!;
+
+ Assert.AreSame(expected, result);
+ if (expected is not null)
+ {
+ foreach (JsonDocument document in expected)
+ {
+ document.Dispose();
+ }
+ }
+ }
+
+ private static (SqlQueryEngine Engine, Mock Executor) CreateEngine()
+ {
+ RuntimeConfig runtimeConfig = new(
+ Schema: string.Empty,
+ DataSource: new DataSource(DatabaseType.MSSQL, string.Empty),
+ Entities: new RuntimeEntities(new Dictionary()));
+ runtimeConfig.UpdateDefaultDataSourceName(DATA_SOURCE_NAME);
+ Mock loader = new(null, null);
+ Mock configProviderMock = new(loader.Object);
+ configProviderMock.Setup(x => x.GetConfig()).Returns(runtimeConfig);
+ RuntimeConfigProvider configProvider = configProviderMock.Object;
+ Mock queryBuilder = new();
+ queryBuilder.Setup(x => x.Build(It.IsAny())).Returns("execute");
+ queryBuilder.Setup(x => x.Build(It.IsAny())).Returns("select");
+ Mock queryExecutor = new();
+ Mock factory = new();
+ factory.Setup(x => x.GetQueryBuilder(DatabaseType.MSSQL)).Returns(queryBuilder.Object);
+ factory.Setup(x => x.GetQueryExecutor(DatabaseType.MSSQL)).Returns(queryExecutor.Object);
+ Mock metadataProviderFactory = new();
+ Mock filterParser = new(configProvider, metadataProviderFactory.Object);
+ DefaultHttpContext httpContext = new();
+
+ SqlQueryEngine engine = new(
+ factory.Object,
+ metadataProviderFactory.Object,
+ new HttpContextAccessor { HttpContext = httpContext },
+ Mock.Of(),
+ filterParser.Object,
+ NullLogger.Instance,
+ configProvider,
+ (DabCacheService)RuntimeHelpers.GetUninitializedObject(typeof(DabCacheService)));
+ return (engine, queryExecutor);
+ }
+
+ private static T CreateUninitializedStructure()
+ {
+ T structure = (T)RuntimeHelpers.GetUninitializedObject(typeof(T));
+ SetProperty(structure!, "EntityName", ENTITY_NAME);
+ SetProperty(structure!, "Parameters", new Dictionary());
+ return structure;
+ }
+
+ private static void SetProperty(object target, string name, object value)
+ {
+ Type? type = target.GetType();
+ while (type is not null)
+ {
+ PropertyInfo? property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
+ if (property is not null)
+ {
+ property.SetValue(target, value);
+ return;
+ }
+
+ FieldInfo? field = type.GetField($"<{name}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
+ if (field is not null)
+ {
+ field.SetValue(target, value);
+ return;
+ }
+
+ type = type.BaseType;
+ }
+
+ Assert.Fail($"Member {name} was not found.");
+ }
+ }
+}