Skip to content
Open
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 @@ -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;
}
Expand All @@ -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))
{
Expand Down
180 changes: 178 additions & 2 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -4108,6 +4111,181 @@ public async Task TestEngineSupportConfigWithNoAuthentication()
}
}

/// <summary>
/// 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.
/// </summary>
[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<Microsoft.Extensions.Hosting.IHostApplicationLifetime>();
Assert.IsTrue(lifetime.ApplicationStarted.IsCancellationRequested, "Host did not finish starting.");
Assert.IsFalse(lifetime.ApplicationStopping.IsCancellationRequested, "Runtime initialization failed.");
RuntimeConfigProvider configProvider = server.Services.GetRequiredService<RuntimeConfigProvider>();
Assert.IsFalse(configProvider.IsLateConfigured);

Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider =
server.Services.GetRequiredService<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider>();
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);
}

/// <summary>
/// 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.
/// </summary>
[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<string>()));
using HttpClient client = server.CreateClient();
client.BaseAddress = new Uri("https://localhost");
RuntimeConfigProvider configProvider = server.Services.GetRequiredService<RuntimeConfigProvider>();
Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider =
server.Services.GetRequiredService<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider>();

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]);
}

/// <summary>
/// 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
Expand Down Expand Up @@ -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
{
Expand Down
15 changes: 7 additions & 8 deletions src/Service/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1120,19 +1120,18 @@ public static ILoggerFactory CreateLoggerFactoryForHostedAndNonHostedScenario(IS
/// <summary>
/// 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.
/// </summary>
/// <param name="services">The service collection where authentication services are added.</param>
/// <param name="runtimeConfigurationProvider">The provider used to load runtime configuration.</param>
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)
Expand All @@ -1151,7 +1150,7 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP
}
else if (authOptions.IsEasyAuthAuthenticationProvider())
{
EasyAuthType easyAuthType = EnumExtensions.Deserialize<EasyAuthType>(runtimeConfig.Runtime.Host.Authentication.Provider);
EasyAuthType easyAuthType = EnumExtensions.Deserialize<EasyAuthType>(authOptions.Provider);
bool isProductionMode = mode != HostMode.Development;
bool appServiceEnvironmentDetected = AppServiceAuthenticationInfo.AreExpectedAppServiceEnvVarsPresent();
bool swaEnvironmentDetected = StaticWebAppsAuthentication.AreExpectedSWAEnvVarsPresent();
Expand Down