From c19883df2d10267e1ef9fd7bc1b0b7e0c8e399e8 Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Tue, 29 Sep 2026 11:28:23 +0530 Subject: [PATCH 1/2] Return 200 empty payload for cached reads with empty result (#3822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3704 (and its duplicate #3446). When entity/runtime **caching is enabled** and a REST/GraphQL read returns **zero rows**, DAB returned **HTTP 500** (`System.InvalidOperationException`) during response serialization. With caching disabled, the identical request correctly returns `200` with an empty payload (e.g. `{"value":[]}`). In the cache read path, `SqlQueryEngine.GetResultInCacheScenario` → `ParseResultIntoJsonDocument(JsonElement? result)`: - For an empty result set, the executor's `GetJsonResultAsync` yields `default(JsonElement)` — a struct whose `ValueKind` is `JsonValueKind.Undefined` and which has **no backing `JsonDocument`**. Because it is a value type, it is not `null`, so the nullable `result` has a value. - `ParseResultIntoJsonDocument` then called `JsonSerializer.SerializeToUtf8Bytes(result)`, which invokes `JsonElement.WriteTo` → `CheckValidInstance()` and throws `InvalidOperationException`, surfaced as HTTP 500. The non-cached path never hits this: it returns a `null` `JsonDocument?` for an empty result, which downstream renders as an empty payload. The cache path was missing that empty guard. The failure occurs on both cache miss and cache hit, and applies to list (`FOR JSON PATH`) and by-PK reads, for **both REST and GraphQL** (this method is shared). Guard the `Undefined` case in `ParseResultIntoJsonDocument` and return `null`, matching the non-cached empty-result path: ```csharp if (result is { ValueKind: JsonValueKind.Undefined }) { return null; } ``` The pre-existing `null`-nullable behavior (serializing to a JSON `null` document) is intentionally left unchanged, since only the `Undefined` element throws. - Added `SqlQueryEngineHelperTests.ParseResultIntoJsonDocument_UndefinedElement_ReturnsNull`, which invokes the method with `default(JsonElement)` and asserts it returns `null` (previously it threw `InvalidOperationException`). - The existing `ParseResultIntoJsonDocument_HandlesValuesAndNull` cases continue to pass unchanged. - All 8 `SqlQueryEngineHelperTests` pass; `dotnet format --verify-no-changes` is clean on the changed files. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: souvikghosh04 <210500244+souvikghosh04@users.noreply.github.com> (cherry picked from commit ddd328d8f71f4d9a4ae668d9a3a21e5727c980fc) --- src/Core/Resolvers/SqlQueryEngine.cs | 8 + .../UnitTests/SqlQueryEngineHelperTests.cs | 209 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/Service.Tests/UnitTests/SqlQueryEngineHelperTests.cs 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/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."); + } + } +} From dcd6050e83d53b6668b41ed4b9f0998d414d8715 Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Tue, 29 Sep 2026 12:33:16 +0530 Subject: [PATCH 2/2] Resolve Custom JWT auth provider to the registered Bearer scheme (#3821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #3541. Requests fail with `Custom` (and any non out‑of‑box JWT) authentication provider: ``` System.InvalidOperationException: No authentication handler is registered for the scheme 'OAuthAuthentication'. The registered schemes are: StaticWebAppsAuthentication, AppServiceAuthentication, Bearer, SimulatorAuthentication. ``` This affects normal REST/GraphQL requests as well as the `/api/openapi` (Swagger) endpoint. ## Root cause An internal authentication‑scheme name mismatch: - For a JWT‑configured provider (anything that isn't EasyAuth / Simulator / Unauthenticated — e.g. `Custom`), DAB **registers** the JWT bearer handler under `JwtBearerDefaults.AuthenticationScheme` (`"Bearer"`) in both `ConfigureAuthentication` (production) and `ConfigureAuthenticationV2` (development, `AddJwtBearer()` → `"Bearer"`). - At request time, `ClientRoleHeaderAuthenticationMiddleware.ResolveConfiguredAuthNScheme` returned `GenericOAuthDefaults.AUTHENTICATIONSCHEME` (`"OAuthAuthentication"`) for `Custom` — a scheme that is **never registered as a handler anywhere** in the codebase. - `httpContext.AuthenticateAsync("OAuthAuthentication")` therefore throws. `AzureAD` / `EntraID` worked only because they explicitly returned the matching `"Bearer"` scheme. `Custom` is equally a JWT‑bearer provider and must resolve to the same registered scheme. ## Fix - `ResolveConfiguredAuthNScheme` now resolves every JWT‑configured provider (AzureAD, EntraID, and any custom provider such as `Custom`) to `JwtBearerDefaults.AuthenticationScheme`, matching the handler registration. - Removed the now‑unused `GenericOAuthDefaults` class (its `"OAuthAuthentication"` constant was never registered as a handler and was the sole source of the mismatch). ## Tests - Added `JwtTokenAuthenticationUnitTests.TestValidToken_JwtConfiguredProviders`, a regression test that validates a JWT through the middleware for `Custom`, `AzureAD`, and `EntraID` providers and asserts the request is authenticated (HTTP 200). Prior to the fix, the `Custom` case threw `No authentication handler is registered for the scheme 'OAuthAuthentication'`. - All 15 tests in `JwtTokenAuthenticationUnitTests` pass; `dotnet format --verify-no-changes` is clean on the changed files. --------- Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com> (cherry picked from commit 0a4d6292c69170febaa2a819149efeefe0b5d3f2) --- ...lientRoleHeaderAuthenticationMiddleware.cs | 11 ++-- .../GenericOAuthDefaults.cs | 12 ++++- .../JwtTokenAuthenticationUnitTests.cs | 51 +++++++++++++++++-- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs index fa7fdc9a25..02ecf84bed 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/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 =>