Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
12 changes: 11 additions & 1 deletion src/Core/AuthenticationHelpers/GenericOAuthDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@
namespace Azure.DataApiBuilder.Core.AuthenticationHelpers;

/// <summary>
/// Authentication Scheme name for generic OAuth providers.
/// Authentication scheme name previously used for generic OAuth providers.
/// </summary>
/// <remarks>
/// Retained only for backward binary/source compatibility of the public
/// <c>Microsoft.DataApiBuilder.Core</c> package surface. DAB no longer uses this scheme:
/// custom OAuth/JWT providers are authenticated with the registered
/// <see cref="Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme"/>
/// ("Bearer") scheme. The "OAuthAuthentication" scheme was never registered as an authentication
/// handler, so referencing it results in a failed authentication.
/// </remarks>
[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";
}
8 changes: 8 additions & 0 deletions src/Core/Resolvers/SqlQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,46 @@ await SendRequestAndGetHttpContextState(
ignoreCase: true);
}

/// <summary>
/// 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'".
/// </summary>
[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);
}

/// <summary>
/// Test to validate that the user request is treated with anonymous role when
/// the jwt token is missing.
Expand Down Expand Up @@ -302,15 +342,17 @@ public async Task TestInvalidToken_NoSignature()
/// and configures Authentication options with passed in SecurityKey
/// </summary>
/// <param name="key"></param>
/// <param name="provider">Runtime configured identity provider name (e.g. "AzureAD" or a
/// custom OAuth/JWT provider such as "Custom"). All resolve to JWT bearer authentication.</param>
/// <returns>IHost</returns>
private static async Task<IHost> CreateWebHostCustomIssuer(SecurityKey key)
private static async Task<IHost> 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);
Expand Down Expand Up @@ -384,9 +426,10 @@ private static async Task<IHost> CreateWebHostCustomIssuer(SecurityKey key)
private static async Task<HttpContext> 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 =>
Expand Down
209 changes: 209 additions & 0 deletions src/Service.Tests/UnitTests/SqlQueryEngineHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}

/// <summary>
/// ParseResultIntoJsonDocument must return null for an undefined JsonElement from an empty cached read
/// to avoid a serialization exception.
/// </summary>
[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);
}

/// <summary>
/// Verifies stored-procedure execution returns the first result object and maps empty or absent result arrays to null.
/// </summary>
[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<IQueryExecutor> executor) = CreateEngine();
executor.Setup(x => x.ExecuteQueryAsync(
It.IsAny<string>(),
It.IsAny<IDictionary<string, DbConnectionParam>>(),
It.IsAny<Func<DbDataReader, List<string>?, Task<JsonArray>>>(),
DATA_SOURCE_NAME,
It.IsAny<HttpContext?>(),
It.IsAny<List<string>?>()))
.ReturnsAsync(resultArray!);
SqlExecuteStructure structure = CreateUninitializedStructure<SqlExecuteStructure>();
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<JsonDocument?>)method.Invoke(
engine,
new object[] { structure, DATA_SOURCE_NAME })!;

Assert.AreEqual(expectsDocument, result is not null);
}

/// <summary>
/// Verifies list execution passes through either the executor's document list or its null result unchanged.
/// </summary>
[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<IQueryExecutor> executor) = CreateEngine();
List<JsonDocument>? expected = returnList
? new List<JsonDocument> { JsonDocument.Parse("{\"id\":1}") }
: null;
executor.Setup(x => x.ExecuteQueryAsync(
It.IsAny<string>(),
It.IsAny<IDictionary<string, DbConnectionParam>>(),
It.IsAny<Func<DbDataReader, List<string>?, Task<List<JsonDocument>>>>(),
DATA_SOURCE_NAME,
It.IsAny<HttpContext?>(),
It.IsAny<List<string>?>()))
.ReturnsAsync(expected!);
SqlQueryStructure structure = CreateUninitializedStructure<SqlQueryStructure>();
MethodInfo method = typeof(SqlQueryEngine).GetMethod(
"ExecuteListAsync",
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
types: new[] { typeof(SqlQueryStructure), typeof(string) },
modifiers: null)!;

List<JsonDocument>? result = await (Task<List<JsonDocument>?>)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<IQueryExecutor> Executor) CreateEngine()
{
RuntimeConfig runtimeConfig = new(
Schema: string.Empty,
DataSource: new DataSource(DatabaseType.MSSQL, string.Empty),
Entities: new RuntimeEntities(new Dictionary<string, Entity>()));
runtimeConfig.UpdateDefaultDataSourceName(DATA_SOURCE_NAME);
Mock<RuntimeConfigLoader> loader = new(null, null);
Mock<RuntimeConfigProvider> configProviderMock = new(loader.Object);
configProviderMock.Setup(x => x.GetConfig()).Returns(runtimeConfig);
RuntimeConfigProvider configProvider = configProviderMock.Object;
Mock<IQueryBuilder> queryBuilder = new();
queryBuilder.Setup(x => x.Build(It.IsAny<SqlExecuteStructure>())).Returns("execute");
queryBuilder.Setup(x => x.Build(It.IsAny<SqlQueryStructure>())).Returns("select");
Mock<IQueryExecutor> queryExecutor = new();
Mock<IAbstractQueryManagerFactory> factory = new();
factory.Setup(x => x.GetQueryBuilder(DatabaseType.MSSQL)).Returns(queryBuilder.Object);
factory.Setup(x => x.GetQueryExecutor(DatabaseType.MSSQL)).Returns(queryExecutor.Object);
Mock<IMetadataProviderFactory> metadataProviderFactory = new();
Mock<GQLFilterParser> filterParser = new(configProvider, metadataProviderFactory.Object);
DefaultHttpContext httpContext = new();

SqlQueryEngine engine = new(
factory.Object,
metadataProviderFactory.Object,
new HttpContextAccessor { HttpContext = httpContext },
Mock.Of<IAuthorizationResolver>(),
filterParser.Object,
NullLogger<IQueryEngine>.Instance,
configProvider,
(DabCacheService)RuntimeHelpers.GetUninitializedObject(typeof(DabCacheService)));
return (engine, queryExecutor);
}

private static T CreateUninitializedStructure<T>()
{
T structure = (T)RuntimeHelpers.GetUninitializedObject(typeof(T));
SetProperty(structure!, "EntityName", ENTITY_NAME);
SetProperty(structure!, "Parameters", new Dictionary<string, DbConnectionParam>());
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.");
}
}
}