diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ccd69b9600..60b48241fc 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,7 +7,7 @@ - + @@ -34,6 +34,7 @@ + @@ -46,7 +47,7 @@ - + diff --git a/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs index a805c3ab1a..789770c985 100644 --- a/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs @@ -173,7 +173,15 @@ public async Task TestInvalidToken_BadAudience() Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode); Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated); StringValues headerValue = GetChallengeHeader(postMiddlewareContext); - Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The audience '{BAD_AUDIENCE}' is invalid")); + + // Microsoft.IdentityModel.Tokens version 8.8+ scrubs the Audience from the error message + // This behavior can be disabled with AppContext.SetSwitch("Switch.Microsoft.IdentityModel.DoNotScrubExceptions", true); + // See https://aka.ms/identitymodel/app-context-switches + string expectedAudienceInErrorMessage = AppContext.TryGetSwitch("Switch.Microsoft.IdentityModel.DoNotScrubExceptions", out bool isExceptionScrubbingDisabled) && isExceptionScrubbingDisabled + ? BAD_AUDIENCE + : "(null)"; + + Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The audience '{expectedAudienceInErrorMessage}' is invalid")); } /// diff --git a/src/Service.Tests/UnitTests/StartupTests.cs b/src/Service.Tests/UnitTests/StartupTests.cs new file mode 100644 index 0000000000..1e90915cb7 --- /dev/null +++ b/src/Service.Tests/UnitTests/StartupTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StackExchange.Redis; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + [TestClass] + public class StartupTests + { + [DataTestMethod] + [DataRow("localhost:6379", false, DisplayName = "Localhost endpoint without password should NOT use Entra auth.")] + [DataRow("127.0.0.1:6379", false, DisplayName = "IPv4 loopback without password should NOT use Entra auth.")] + [DataRow("[::1]:6379", false, DisplayName = "IPv6 loopback without password should NOT use Entra auth.")] + [DataRow("redis.example.com:6380", true, DisplayName = "Remote endpoint without password SHOULD use Entra auth.")] + [DataRow("redis.example.com:6380,password=secret", false, DisplayName = "Presence of password should NOT use Entra auth, even for remote endpoints.")] + [DataRow("localhost:6379,redis.example.com:6380", true, DisplayName = "Mixed endpoints (including remote) without password SHOULD use Entra auth.")] + [DataRow("localhost:6379,password=secret", false, DisplayName = "Localhost with password should NOT use Entra auth.")] + public void ShouldUseEntraAuthForRedis(string connectionString, bool expectedUseEntraAuth) + { + // Arrange + var options = ConfigurationOptions.Parse(connectionString); + + // Act + bool result = Startup.ShouldUseEntraAuthForRedis(options); + + // Assert + Assert.AreEqual(expectedUseEntraAuth, result); + } + } +} diff --git a/src/Service/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index 5cf762ca57..d0478b83ed 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -64,6 +64,7 @@ + diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 333bf57234..b51c7c31ac 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -3,6 +3,8 @@ using System; using System.IO.Abstractions; +using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; @@ -435,7 +437,7 @@ public void ConfigureServices(IServiceCollection services) else { // NOTE: this is done to reuse the same connection multiplexer for both the cache and backplane - Task connectionMultiplexerTask = ConnectionMultiplexer.ConnectAsync(level2CacheOptions.ConnectionString); + Task connectionMultiplexerTask = CreateConnectionMultiplexerAsync(level2CacheOptions.ConnectionString); fusionCacheBuilder .WithSerializer(new FusionCacheSystemTextJsonSerializer()) @@ -470,6 +472,46 @@ public void ConfigureServices(IServiceCollection services) services.AddControllers(); } + /// + /// Creates a ConnectionMultiplexer for Redis with support for Azure Entra authentication. + /// + /// The Redis connection string. + /// A task that represents the asynchronous operation. The task result contains the connected IConnectionMultiplexer. + private static async Task CreateConnectionMultiplexerAsync(string connectionString) + { + ConfigurationOptions options = ConfigurationOptions.Parse(connectionString); + + if (ShouldUseEntraAuthForRedis(options)) + { + options = await options.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()); + } + + return await ConnectionMultiplexer.ConnectAsync(options); + } + + /// + /// Determines whether Azure Entra authentication should be used. + /// Conditions: + /// - No password provided + /// - At least one endpoint is NOT localhost/loopback + /// + /// The Redis configuration options. + /// True if Azure Entra authentication should be used; otherwise, false. + /// Internal for testing. + internal static bool ShouldUseEntraAuthForRedis(ConfigurationOptions options) + { + // Determine if an endpoint is localhost/loopback + static bool IsLocalhostEndpoint(EndPoint ep) => ep switch + { + DnsEndPoint dns => string.Equals(dns.Host, "localhost", StringComparison.OrdinalIgnoreCase), + IPEndPoint ip => IPAddress.IsLoopback(ip.Address), + _ => false, + }; + + return string.IsNullOrEmpty(options.Password) + && options.EndPoints.Any(ep => !IsLocalhostEndpoint(ep)); + } + /// /// Configure GraphQL services within the service collection of the /// request pipeline.