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."); + } + } +}