From 91a25239de1fb7bf9667c1b77e0e7c77c5f12e94 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 10 Sep 2026 00:00:23 -0700 Subject: [PATCH] MSRC edge case fix for omitted authentication property --- ...lientRoleHeaderAuthenticationMiddleware.cs | 10 +- .../Configuration/ConfigurationTests.cs | 180 +++++++++++++++++- src/Service/Startup.cs | 15 +- 3 files changed, 190 insertions(+), 15 deletions(-) diff --git a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs index fa7fdc9a25..44dac71360 100644 --- a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs +++ b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs @@ -181,7 +181,11 @@ public static bool IsSystemRole(string roleName) private static string ResolveConfiguredAuthNScheme(string? configuredProviderName) { if (string.IsNullOrWhiteSpace(configuredProviderName) - || string.Equals(configuredProviderName, SupportedAuthNProviders.STATIC_WEB_APPS, StringComparison.OrdinalIgnoreCase)) + || string.Equals(configuredProviderName, SupportedAuthNProviders.UNAUTHENTICATED, StringComparison.OrdinalIgnoreCase)) + { + return UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME; + } + else if (string.Equals(configuredProviderName, SupportedAuthNProviders.STATIC_WEB_APPS, StringComparison.OrdinalIgnoreCase)) { return EasyAuthAuthenticationDefaults.SWAAUTHSCHEME; } @@ -193,10 +197,6 @@ private static string ResolveConfiguredAuthNScheme(string? configuredProviderNam { return SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME; } - else if (string.Equals(configuredProviderName, SupportedAuthNProviders.UNAUTHENTICATED, StringComparison.OrdinalIgnoreCase)) - { - return UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME; - } else if (string.Equals(configuredProviderName, SupportedAuthNProviders.AZURE_AD, StringComparison.OrdinalIgnoreCase) || string.Equals(configuredProviderName, SupportedAuthNProviders.ENTRA_ID, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 2c7d3ff4b0..207a374a74 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -15,6 +15,7 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using System.Web; @@ -24,6 +25,7 @@ using Azure.DataApiBuilder.Config.Telemetry; using Azure.DataApiBuilder.Core; using Azure.DataApiBuilder.Core.AuthenticationHelpers; +using Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthentication; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; @@ -43,6 +45,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; @@ -4108,6 +4111,181 @@ public async Task TestEngineSupportConfigWithNoAuthentication() } } + /// + /// Ensures a cold-started runtime with omitted authentication ignores forged EasyAuth headers, + /// including in development mode where all authentication handlers remain registered for hot reload. + /// + [DataTestMethod] + [DataRow(HostMode.Production, EasyAuthType.StaticWebApps)] + [DataRow(HostMode.Production, EasyAuthType.AppService)] + [DataRow(HostMode.Development, EasyAuthType.StaticWebApps)] + [DataRow(HostMode.Development, EasyAuthType.AppService)] + [DoNotParallelize] + public async Task TestColdStartOmittedAuthenticationIgnoresForgedEasyAuthHeader(HostMode hostMode, EasyAuthType payloadType) + { + TestHelper.UnsetAllDABEnvironmentVariables(); + Assert.IsNull(Environment.GetEnvironmentVariable(AppServiceAuthenticationInfo.APPSERVICESAUTH_ENABLED_ENVVAR)); + Assert.IsNull(Environment.GetEnvironmentVariable(StaticWebAppsAuthentication.WEBSITE_SITE_NAME_ENVVAR)); + + RuntimeConfig configuration = CreateBasicRuntimeConfigWithNoEntity( + DatabaseType.MSSQL, + "Server=placeholder;"); + RuntimeOptions runtimeOptions = configuration.Runtime! with + { + Host = new(Cors: null, Authentication: null, Mode: hostMode) + }; + configuration = configuration with { Runtime = runtimeOptions }; + JsonObject configObject = JsonNode.Parse(configuration.ToJson())!.AsObject(); + JsonObject host = configObject["runtime"]!["host"]!.AsObject(); + Assert.IsTrue(host.Remove("authentication")); + string serializedConfiguration = configObject.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + Assert.IsFalse(host.ContainsKey("authentication")); + File.WriteAllText(CUSTOM_CONFIG_FILENAME, serializedConfiguration); + + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; + using TestServer server = new(Program.CreateWebHostBuilder(args)); + Microsoft.Extensions.Hosting.IHostApplicationLifetime lifetime = + server.Services.GetRequiredService(); + Assert.IsTrue(lifetime.ApplicationStarted.IsCancellationRequested, "Host did not finish starting."); + Assert.IsFalse(lifetime.ApplicationStopping.IsCancellationRequested, "Runtime initialization failed."); + RuntimeConfigProvider configProvider = server.Services.GetRequiredService(); + Assert.IsFalse(configProvider.IsLateConfigured); + + Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider = + server.Services.GetRequiredService(); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME)); + Microsoft.AspNetCore.Authentication.AuthenticationScheme? defaultScheme = + await schemeProvider.GetDefaultAuthenticateSchemeAsync(); + if (hostMode == HostMode.Development) + { + // With all handlers available and no default, request-time selection must ignore EasyAuth. + Assert.IsNull(defaultScheme); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME)); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.SWAAUTHSCHEME)); + } + else + { + Assert.IsNotNull(defaultScheme); + Assert.AreEqual(UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME, defaultScheme.Name); + Assert.IsNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME)); + Assert.IsNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.SWAAUTHSCHEME)); + } + + const string FORGED_ROLE = "ForgedRole"; + string forgedPrincipal = payloadType == EasyAuthType.StaticWebApps + ? AuthTestHelper.CreateStaticWebAppsEasyAuthToken(addAuthenticated: true, specificRole: FORGED_ROLE) + : AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim { Typ = AuthenticationOptions.ROLE_CLAIM_TYPE, Val = FORGED_ROLE } + ]); + HttpContext context = await server.SendAsync(requestContext => + { + requestContext.Request.Path = "/api/not-an-entity"; + requestContext.Request.Headers[AuthenticationOptions.CLIENT_PRINCIPAL_HEADER] = forgedPrincipal; + requestContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = FORGED_ROLE; + requestContext.Request.Scheme = "https"; + }); + + Assert.AreEqual(StatusCodes.Status404NotFound, context.Response.StatusCode); + Assert.IsNotNull(context.User.Identity); + Assert.IsFalse(context.User.Identity.IsAuthenticated); + Assert.IsFalse(context.User.IsInRole(FORGED_ROLE)); + Assert.AreEqual( + AuthorizationType.Anonymous.ToString(), + context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER], + ignoreCase: true); + } + + /// + /// Preserves the existing late-configuration bootstrap and App Service request path. + /// Both EasyAuth handlers remain registered. Header trust in this mode is the hosting + /// service's responsibility; this in-process test simulates its authenticated ingress. + /// + [DataTestMethod] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + [DoNotParallelize] + public async Task TestLateConfigurationPreservesEasyAuthSchemes(string configurationEndpoint) + { + TestHelper.UnsetAllDABEnvironmentVariables(); + + using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + using HttpClient client = server.CreateClient(); + client.BaseAddress = new Uri("https://localhost"); + RuntimeConfigProvider configProvider = server.Services.GetRequiredService(); + Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider = + server.Services.GetRequiredService(); + + Assert.IsTrue(configProvider.IsLateConfigured); + Assert.IsFalse(configProvider.TryGetLoadedConfig(out _)); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME)); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.SWAAUTHSCHEME)); + Assert.IsNull(await schemeProvider.GetSchemeAsync(UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME)); + + Microsoft.AspNetCore.Authentication.AuthenticationScheme? defaultScheme = + await schemeProvider.GetDefaultAuthenticateSchemeAsync(); + Assert.IsNotNull(defaultScheme); + Assert.AreEqual(EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME, defaultScheme.Name); + + using HttpResponseMessage beforeHydration = await client.GetAsync("/api/not-an-entity"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, beforeHydration.StatusCode); + + RuntimeConfig configuration = CreateBasicRuntimeConfigWithNoEntity( + DatabaseType.MSSQL, + "Server=placeholder;"); + RuntimeOptions runtimeOptions = configuration.Runtime! with + { + Host = new( + Cors: null, + Authentication: new(Provider: EasyAuthType.AppService.ToString()), + Mode: HostMode.Production) + }; + configuration = configuration with { Runtime = runtimeOptions }; + using HttpRequestMessage hydrationRequest = new(HttpMethod.Post, configurationEndpoint) + { + Content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint) + }; + // Honor an externally configured bootstrap token without changing process-wide state + // or including the token on subsequent data requests. + string? bootstrapToken = Environment.GetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR); + if (!string.IsNullOrEmpty(bootstrapToken)) + { + hydrationRequest.Headers.Add(Startup.CONFIG_AUTH_HEADER, bootstrapToken); + } + + using HttpResponseMessage hydrationResponse = await client.SendAsync(hydrationRequest); + Assert.AreEqual(HttpStatusCode.OK, hydrationResponse.StatusCode); + Assert.IsTrue(configProvider.IsLateConfigured); + Assert.IsTrue(configProvider.TryGetLoadedConfig(out _)); + Assert.IsNull(Environment.GetEnvironmentVariable(AppServiceAuthenticationInfo.APPSERVICESAUTH_ENABLED_ENVVAR)); + Assert.IsNull(Environment.GetEnvironmentVariable(StaticWebAppsAuthentication.WEBSITE_SITE_NAME_ENVVAR)); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME)); + Assert.IsNotNull(await schemeProvider.GetSchemeAsync(EasyAuthAuthenticationDefaults.SWAAUTHSCHEME)); + + const string REQUIRED_ROLE = "LateConfiguredRole"; + string principal = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim { Typ = AuthenticationOptions.ROLE_CLAIM_TYPE, Val = REQUIRED_ROLE } + ]); + HttpContext context = await server.SendAsync(requestContext => + { + requestContext.Request.Path = "/api/not-an-entity"; + requestContext.Request.Headers[AuthenticationOptions.CLIENT_PRINCIPAL_HEADER] = principal; + requestContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = REQUIRED_ROLE; + requestContext.Request.Scheme = "https"; + }); + + Assert.AreEqual(StatusCodes.Status404NotFound, context.Response.StatusCode); + Assert.IsNotNull(context.User.Identity); + Assert.IsTrue(context.User.Identity.IsAuthenticated); + Assert.IsTrue(context.User.IsInRole(REQUIRED_ROLE)); + Assert.AreEqual(REQUIRED_ROLE, context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]); + } + /// /// In CosmosDB NoSQL, we store data in the form of JSON. Practically, JSON can be very complex. /// But DAB doesn't support JSON with circular references e.g if 'Character.Moon' is a valid JSON Path, then @@ -4251,8 +4429,6 @@ public void TestProductionModeAppServiceEnvironmentCheck(HostMode hostMode, Easy $"--ConfigFileName={CUSTOM_CONFIG}" }; - // When host is in Production mode with AppService as Identity Provider and the environment variables are not set - // we do not throw an exception any longer(PR: 2943), instead log a warning to the user. In this case expectError is false. // This test only checks for startup errors, so no requests are sent to the test server. try { diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index b41550bf2e..5ecc77da53 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -1120,19 +1120,18 @@ public static ILoggerFactory CreateLoggerFactoryForHostedAndNonHostedScenario(IS /// /// Add services necessary for Authentication Middleware and based on the loaded /// runtime configuration set the AuthenticationOptions to be either - /// EasyAuth based (by default) or JwtBearerOptions. - /// When no runtime configuration is set on engine startup, set the - /// default authentication scheme to EasyAuth. + /// Unauthenticated (by default), EasyAuth based, or JwtBearerOptions. + /// When no runtime configuration is available on engine startup, set the + /// default authentication scheme to EasyAuth for late configuration. /// /// The service collection where authentication services are added. /// The provider used to load runtime configuration. private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigProvider runtimeConfigurationProvider) { - if (runtimeConfigurationProvider.TryGetConfig(out RuntimeConfig? runtimeConfig) && - runtimeConfig.Runtime?.Host?.Authentication is not null) + if (runtimeConfigurationProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - AuthenticationOptions authOptions = runtimeConfig.Runtime.Host.Authentication; - HostMode mode = runtimeConfig.Runtime.Host.Mode; + AuthenticationOptions authOptions = runtimeConfig.Runtime?.Host?.Authentication ?? new(); + HostMode mode = runtimeConfig.Runtime?.Host?.Mode ?? HostMode.Production; if (authOptions.IsJwtConfiguredIdentityProvider()) { services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -1151,7 +1150,7 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP } else if (authOptions.IsEasyAuthAuthenticationProvider()) { - EasyAuthType easyAuthType = EnumExtensions.Deserialize(runtimeConfig.Runtime.Host.Authentication.Provider); + EasyAuthType easyAuthType = EnumExtensions.Deserialize(authOptions.Provider); bool isProductionMode = mode != HostMode.Development; bool appServiceEnvironmentDetected = AppServiceAuthenticationInfo.AreExpectedAppServiceEnvVarsPresent(); bool swaEnvironmentDetected = StaticWebAppsAuthentication.AreExpectedSWAEnvVarsPresent();