diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 920c0a4da6..5886cb41e0 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -368,6 +368,10 @@ { "const": "Custom", "description": "Custom authentication provider defined by the user. Use the JWT property to configure the custom provider." + }, + { + "const": "Unauthenticated", + "description": "Unauthenticated provider where all operations run as anonymous. Use when Data API builder is behind an app gateway or APIM where authentication is handled externally." } ], "default": "AppService" diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 073f349a67..557c8ecbdb 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -635,6 +635,7 @@ public void TestUpdateCorsAllowCredentialsHostSettings(bool allowCredentialsValu [DataRow("Appservice", DisplayName = "Update authentication.provider to AppService for Host.")] [DataRow("azuread", DisplayName = "Update authentication.provider to AzureAD for Host.")] [DataRow("entraid", DisplayName = "Update authentication.provider to EntraID for Host.")] + [DataRow("Unauthenticated", DisplayName = "Update authentication.provider to Unauthenticated for Host.")] public void TestUpdateAuthenticationProviderHostSettings(string authenticationProviderValue) { // Arrange -> all the setup which includes creating options. diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index 99a9b77b6e..685b479312 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -1121,12 +1121,15 @@ public async Task TestExitOfRuntimeEngineWithInvalidConfig( [DataRow("AppService", true)] [DataRow("AzureAD", true)] [DataRow("EntraID", true)] + [DataRow("Unauthenticated", true)] public void TestBaseRouteIsConfigurableForSWA(string authProvider, bool isExceptionExpected) { string[] initArgs = { "init", "-c", TEST_RUNTIME_CONFIG_FILE, "--host-mode", "development", "--database-type", "mssql", "--connection-string", SAMPLE_TEST_CONN_STRING, "--auth.provider", authProvider, "--runtime.base-route", "base-route" }; - if (!Enum.TryParse(authProvider, ignoreCase: true, out EasyAuthType _)) + if (!Enum.TryParse(authProvider, ignoreCase: true, out EasyAuthType _) && + !authProvider.Equals("Unauthenticated", StringComparison.OrdinalIgnoreCase) && + !authProvider.Equals("Simulator", StringComparison.OrdinalIgnoreCase)) { string[] audIssuers = { "--auth.audience", "aud-xxx", "--auth.issuer", "issuer-xxx" }; initArgs = initArgs.Concat(audIssuers).ToArray(); diff --git a/src/Cli.Tests/InitTests.cs b/src/Cli.Tests/InitTests.cs index 051bfdf7a7..96ba1ad66b 100644 --- a/src/Cli.Tests/InitTests.cs +++ b/src/Cli.Tests/InitTests.cs @@ -301,6 +301,7 @@ public void EnsureFailureOnReInitializingExistingConfig() [DataRow("StaticWebApps", null, null, DisplayName = "StaticWebApps with no audience and no issuer specified.")] [DataRow("AppService", null, null, DisplayName = "AppService with no audience and no issuer specified.")] [DataRow("Simulator", null, null, DisplayName = "Simulator with no audience and no issuer specified.")] + [DataRow("Unauthenticated", null, null, DisplayName = "Unauthenticated with no audience and no issuer specified.")] [DataRow("AzureAD", "aud-xxx", "issuer-xxx", DisplayName = "AzureAD with both audience and issuer specified.")] [DataRow("EntraID", "aud-xxx", "issuer-xxx", DisplayName = "EntraID with both audience and issuer specified.")] public Task EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders( diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_47836da0dfbdc458.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_47836da0dfbdc458.verified.txt new file mode 100644 index 0000000000..55843cf207 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_47836da0dfbdc458.verified.txt @@ -0,0 +1,50 @@ +{ + DataSource: { + DatabaseType: MSSQL, + Options: { + set-session-context: false + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Mcp: { + Enabled: true, + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: Unauthenticated + }, + Mode: Production + } + }, + Entities: [] +} diff --git a/src/Cli.Tests/UtilsTests.cs b/src/Cli.Tests/UtilsTests.cs index b02649339d..3b7a108867 100644 --- a/src/Cli.Tests/UtilsTests.cs +++ b/src/Cli.Tests/UtilsTests.cs @@ -203,6 +203,10 @@ public void TestApiPathIsWellFormed(string apiPath, bool expectSuccess) [DataRow("Simulator", null, "issuer-xxx", true, DisplayName = "PASS: Issuer ignored with Simulator.")] [DataRow("Simulator", "aud-xxx", null, true, DisplayName = "PASS: Audience ignored with Simulator.")] [DataRow("Simulator", null, null, true, DisplayName = "PASS: Simulator correctly configured with neither audience nor issuer.")] + [DataRow("Unauthenticated", "aud-xxx", "issuer-xxx", true, DisplayName = "PASS: Audience and Issuer ignored with Unauthenticated.")] + [DataRow("Unauthenticated", null, "issuer-xxx", true, DisplayName = "PASS: Issuer ignored with Unauthenticated.")] + [DataRow("Unauthenticated", "aud-xxx", null, true, DisplayName = "PASS: Audience ignored with Unauthenticated.")] + [DataRow("Unauthenticated", null, null, true, DisplayName = "PASS: Unauthenticated correctly configured with neither audience nor issuer.")] [DataRow("AzureAD", "aud-xxx", "issuer-xxx", true, DisplayName = "PASS: AzureAD correctly configured with both audience and issuer.")] [DataRow("AzureAD", null, "issuer-xxx", false, DisplayName = "FAIL: AzureAD incorrectly configured with no audience specified.")] [DataRow("AzureAD", "aud-xxx", null, false, DisplayName = "FAIL: AzureAD incorrectly configured with no issuer specified.")] diff --git a/src/Cli.Tests/ValidateConfigTests.cs b/src/Cli.Tests/ValidateConfigTests.cs index e40a32e291..22364712e7 100644 --- a/src/Cli.Tests/ValidateConfigTests.cs +++ b/src/Cli.Tests/ValidateConfigTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; using Serilog; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 648edc1950..c501a6c790 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -2444,6 +2444,22 @@ public static bool IsConfigValid(ValidateOptions options, FileSystemRuntimeConfi } } } + + // Warn if Unauthenticated provider is used with authenticated or custom roles + if (config.Runtime?.Host?.Authentication?.IsUnauthenticatedAuthenticationProvider() == true) + { + bool hasNonAnonymousRoles = config.Entities + .Where(e => e.Value.Permissions is not null) + .SelectMany(e => e.Value.Permissions!) + .Any(p => !p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase)); + + if (hasNonAnonymousRoles) + { + _logger.LogWarning( + "Authentication provider is 'Unauthenticated' but some entities have permissions configured for non-anonymous roles. " + + "All requests will be treated as anonymous."); + } + } } } diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index 48edd4411c..c1ff7f2a99 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -516,11 +516,12 @@ public static bool ValidateAudienceAndIssuerForJwtProvider( string? issuer) { if (Enum.TryParse(authenticationProvider, ignoreCase: true, out _) - || AuthenticationOptions.SIMULATOR_AUTHENTICATION == authenticationProvider) + || AuthenticationOptions.SIMULATOR_AUTHENTICATION.Equals(authenticationProvider, StringComparison.OrdinalIgnoreCase) + || AuthenticationOptions.UNAUTHENTICATED_AUTHENTICATION.Equals(authenticationProvider, StringComparison.OrdinalIgnoreCase)) { if (!(string.IsNullOrWhiteSpace(audience)) || !(string.IsNullOrWhiteSpace(issuer))) { - _logger.LogWarning("Audience and Issuer can't be set for EasyAuth or Simulator authentication."); + _logger.LogWarning("Audience and Issuer can't be set for EasyAuth, Simulator, or Unauthenticated authentication."); return true; } } @@ -528,7 +529,7 @@ public static bool ValidateAudienceAndIssuerForJwtProvider( { if (string.IsNullOrWhiteSpace(audience) || string.IsNullOrWhiteSpace(issuer)) { - _logger.LogError($"Authentication providers other than EasyAuth and Simulator require both Audience and Issuer."); + _logger.LogError($"Authentication providers other than EasyAuth, Simulator, and Unauthenticated require both Audience and Issuer."); return false; } } diff --git a/src/Config/ObjectModel/AuthenticationOptions.cs b/src/Config/ObjectModel/AuthenticationOptions.cs index a937168493..b12534412a 100644 --- a/src/Config/ObjectModel/AuthenticationOptions.cs +++ b/src/Config/ObjectModel/AuthenticationOptions.cs @@ -32,9 +32,17 @@ public record AuthenticationOptions(string Provider = nameof(EasyAuthType.AppSer /// True when development mode should authenticate all requests. public bool IsAuthenticationSimulatorEnabled() => Provider.Equals(SIMULATOR_AUTHENTICATION, StringComparison.OrdinalIgnoreCase); + public const string UNAUTHENTICATED_AUTHENTICATION = "Unauthenticated"; + + /// + /// Returns whether the configured Provider value matches the Unauthenticated authentication type. + /// + /// True if Provider is Unauthenticated type. + public bool IsUnauthenticatedAuthenticationProvider() => Provider.Equals(UNAUTHENTICATED_AUTHENTICATION, StringComparison.OrdinalIgnoreCase); + /// /// A shorthand method to determine whether JWT is configured for the current authentication provider. /// /// True if the provider is enabled for JWT, otherwise false. - public bool IsJwtConfiguredIdentityProvider() => !IsEasyAuthAuthenticationProvider() && !IsAuthenticationSimulatorEnabled(); + public bool IsJwtConfiguredIdentityProvider() => !IsEasyAuthAuthenticationProvider() && !IsAuthenticationSimulatorEnabled() && !IsUnauthenticatedAuthenticationProvider(); }; diff --git a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs index c83de9ed3a..fa7fdc9a25 100644 --- a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs +++ b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator; +using Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthentication; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; @@ -192,6 +193,10 @@ 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/Core/AuthenticationHelpers/SupportedAuthNProviders.cs b/src/Core/AuthenticationHelpers/SupportedAuthNProviders.cs index 70a6809074..cc543ee28e 100644 --- a/src/Core/AuthenticationHelpers/SupportedAuthNProviders.cs +++ b/src/Core/AuthenticationHelpers/SupportedAuthNProviders.cs @@ -14,4 +14,6 @@ internal static class SupportedAuthNProviders public const string SIMULATOR = "Simulator"; public const string STATIC_WEB_APPS = "StaticWebApps"; + + public const string UNAUTHENTICATED = "Unauthenticated"; } diff --git a/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationBuilderExtensions.cs b/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000000..aa15d04933 --- /dev/null +++ b/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationBuilderExtensions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Authentication; + +namespace Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthentication; + +/// +/// Extension methods related to Unauthenticated authentication. +/// This class allows setting up Unauthenticated authentication in the startup class with +/// a single call to .AddAuthentication(scheme).AddUnauthenticatedAuthentication() +/// +public static class UnauthenticatedAuthenticationBuilderExtensions +{ + /// + /// Add authentication with Unauthenticated provider. + /// + /// Authentication builder. + /// The builder, to chain commands. + public static AuthenticationBuilder AddUnauthenticatedAuthentication(this AuthenticationBuilder builder) + { + if (builder is null) + { + throw new System.ArgumentNullException(nameof(builder)); + } + + builder.AddScheme( + authenticationScheme: UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME, + displayName: UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME, + configureOptions: null); + + return builder; + } +} diff --git a/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationDefaults.cs b/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationDefaults.cs new file mode 100644 index 0000000000..ff7f0f2a73 --- /dev/null +++ b/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationDefaults.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthentication; + +/// +/// Default values related to UnauthenticatedAuthentication handler. +/// +public static class UnauthenticatedAuthenticationDefaults +{ + /// + /// The default value used for UnauthenticatedAuthenticationOptions.AuthenticationScheme. + /// + public const string AUTHENTICATIONSCHEME = "UnauthenticatedAuthentication"; +} diff --git a/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationHandler.cs b/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationHandler.cs new file mode 100644 index 0000000000..c2f0a949fb --- /dev/null +++ b/src/Core/AuthenticationHelpers/UnauthenticatedAuthentication/UnauthenticatedAuthenticationHandler.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthentication; + +/// +/// This class is used to best integrate with ASP.NET Core AuthenticationHandler base class. +/// Ref: https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Core/src/AuthenticationHandler.cs +/// When "Unauthenticated" is configured, this handler authenticates the user as anonymous, +/// without reading any HTTP authentication headers. +/// +public class UnauthenticatedAuthenticationHandler : AuthenticationHandler +{ + /// + /// Constructor for the UnauthenticatedAuthenticationHandler. + /// Note the parameters are required by the base class. + /// + /// Authentication options. + /// Logger factory. + /// URL encoder. + public UnauthenticatedAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + /// + /// Returns an unauthenticated ClaimsPrincipal for all requests. + /// The ClaimsPrincipal has no identity and no claims, representing an anonymous user. + /// + /// An authentication result to ASP.NET Core library authentication mechanisms + protected override Task HandleAuthenticateAsync() + { + // ClaimsIdentity without authenticationType means the user is not authenticated (anonymous) + ClaimsIdentity identity = new(); + ClaimsPrincipal claimsPrincipal = new(identity); + + AuthenticationTicket ticket = new(claimsPrincipal, UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME); + AuthenticateResult success = AuthenticateResult.Success(ticket); + return Task.FromResult(success); + } +} diff --git a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs index 846a34ebee..9a2a9e57dd 100644 --- a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs +++ b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs @@ -183,6 +183,25 @@ public void ValidateFailureWithUnneededEasyAuthConfig() }); } + [TestMethod("Unauthenticated provider is correctly identified by IsUnauthenticatedAuthenticationProvider method")] + public void ValidateUnauthenticatedProviderIdentification() + { + // Test with Unauthenticated provider + AuthenticationOptions unauthenticatedOptions = new(Provider: "Unauthenticated"); + Assert.IsTrue(unauthenticatedOptions.IsUnauthenticatedAuthenticationProvider()); + + // Test case-insensitivity + AuthenticationOptions unauthenticatedOptionsLower = new(Provider: "unauthenticated"); + Assert.IsTrue(unauthenticatedOptionsLower.IsUnauthenticatedAuthenticationProvider()); + + // Test that other providers are not identified as Unauthenticated + AuthenticationOptions appServiceOptions = new(Provider: "AppService"); + Assert.IsFalse(appServiceOptions.IsUnauthenticatedAuthenticationProvider()); + + AuthenticationOptions simulatorOptions = new(Provider: "Simulator"); + Assert.IsFalse(simulatorOptions.IsUnauthenticatedAuthenticationProvider()); + } + private static RuntimeConfig CreateRuntimeConfigWithOptionalAuthN(AuthenticationOptions authNConfig = null) { DataSource dataSource = new(DatabaseType.MSSQL, DEFAULT_CONNECTION_STRING, new()); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 333bf57234..e952748e63 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Config.Utilities; using Azure.DataApiBuilder.Core.AuthenticationHelpers; using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator; +using Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthentication; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; @@ -772,7 +773,7 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP { AuthenticationOptions authOptions = runtimeConfig.Runtime.Host.Authentication; HostMode mode = runtimeConfig.Runtime.Host.Mode; - if (!authOptions.IsAuthenticationSimulatorEnabled() && !authOptions.IsEasyAuthAuthenticationProvider()) + if (!authOptions.IsAuthenticationSimulatorEnabled() && !authOptions.IsEasyAuthAuthenticationProvider() && !authOptions.IsUnauthenticatedAuthenticationProvider()) { services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => @@ -809,6 +810,11 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP _logger.LogInformation("Registered EasyAuth scheme: {Scheme}", defaultScheme); } + else if (authOptions.IsUnauthenticatedAuthenticationProvider()) + { + services.AddAuthentication(UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME) + .AddUnauthenticatedAuthentication(); + } else if (mode == HostMode.Development && authOptions.IsAuthenticationSimulatorEnabled()) { services.AddAuthentication(SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME) @@ -850,7 +856,8 @@ private static void ConfigureAuthenticationV2(IServiceCollection services, Runti services.AddAuthentication() .AddEnvDetectedEasyAuth() .AddJwtBearer() - .AddSimulatorAuthentication(); + .AddSimulatorAuthentication() + .AddUnauthenticatedAuthentication(); } ///