diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index b684cc28ac..b441b57303 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/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/ValidateConfigTests.cs b/src/Cli.Tests/ValidateConfigTests.cs index e40a32e291..03cac6de82 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; @@ -359,4 +360,58 @@ private async Task ValidatePropertyOptionsFails(ConfigureOptions options) JsonSchemaValidationResult result = await validator.ValidateConfigSchema(config, TEST_RUNTIME_CONFIG_FILE, mockLoggerFactory.Object); Assert.IsFalse(result.IsValid); } + + /// + /// Test that the Unauthenticated provider is correctly identified by the IsUnauthenticatedAuthenticationProvider method. + /// + [TestMethod] + public void TestIsUnauthenticatedAuthenticationProviderMethod() + { + // 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()); + } + + /// + /// Test that Unauthenticated provider does not require JWT configuration. + /// + [TestMethod] + public void TestUnauthenticatedProviderDoesNotRequireJwt() + { + AuthenticationOptions unauthenticatedOptions = new(Provider: "Unauthenticated"); + Assert.IsFalse(unauthenticatedOptions.IsJwtConfiguredIdentityProvider()); + } + + /// + /// Test that entities with non-anonymous roles are correctly identified when + /// Unauthenticated provider is configured. This validates the core detection logic + /// used by IsConfigValid to emit warnings. + /// + [DataTestMethod] + [DataRow("authenticated", true, DisplayName = "Authenticated role should be flagged as non-anonymous")] + [DataRow("customRole", true, DisplayName = "Custom role should be flagged as non-anonymous")] + [DataRow("anonymous", false, DisplayName = "Anonymous role should not be flagged")] + [DataRow("Anonymous", false, DisplayName = "Anonymous role (case-insensitive) should not be flagged")] + public void TestUnauthenticatedProviderNonAnonymousRoleDetection(string role, bool shouldWarn) + { + // Arrange: Create an entity permission with the specified role + EntityPermission permission = new(Role: role, Actions: new EntityAction[] { new(Action: EntityActionOperation.Read, Fields: null, Policy: null) }); + + // Act: Check if the role is non-anonymous (the logic used in IsConfigValid) + bool isNonAnonymous = !permission.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase); + + // Assert: Verify the detection logic works correctly + Assert.AreEqual(shouldWarn, isNonAnonymous, $"Role '{role}' detection mismatch"); + } } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 648edc1950..ec9b0cd3d3 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) + { + foreach (KeyValuePair entity in config.Entities.Where(e => e.Value.Permissions is not null)) + { + foreach (EntityPermission permission in entity.Value.Permissions!.Where(p => !p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning( + "Entity '{EntityName}' has permission configured for role '{Role}' but authentication provider is 'Unauthenticated'. " + + "All requests will be treated as anonymous.", + entity.Key, + permission.Role); + } + } + } } } 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..036f55876f 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 when all operations run as anonymous. + 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..513e81a137 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.UnauthenticatedAuthenticationHandler; 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/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationBuilderExtensions.cs b/src/Core/AuthenticationHelpers/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000000..6cf6b79e94 --- /dev/null +++ b/src/Core/AuthenticationHelpers/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationBuilderExtensions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Authentication; + +namespace Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthenticationHandler; + +/// +/// 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/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationDefaults.cs b/src/Core/AuthenticationHelpers/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationDefaults.cs new file mode 100644 index 0000000000..03f8222ecd --- /dev/null +++ b/src/Core/AuthenticationHelpers/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationDefaults.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Core.AuthenticationHelpers.UnauthenticatedAuthenticationHandler; + +/// +/// 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/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationHandler.cs b/src/Core/AuthenticationHelpers/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationHandler.cs new file mode 100644 index 0000000000..552e44b228 --- /dev/null +++ b/src/Core/AuthenticationHelpers/UnauthenticatedAuthenticationHandler/UnauthenticatedAuthenticationHandler.cs @@ -0,0 +1,49 @@ +// 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.UnauthenticatedAuthenticationHandler; + +/// +/// This class is used to best integrate with ASP.NET Core AuthenticationHandler base class. +/// 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/Startup.cs b/src/Service/Startup.cs index 333bf57234..e61154fc90 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.UnauthenticatedAuthenticationHandler; 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(); } ///