From a02948a1a63846b7866466b3b93a8b24b3e5aa36 Mon Sep 17 00:00:00 2001 From: Paul Harrington Date: Fri, 9 Jan 2026 17:29:30 -0800 Subject: [PATCH 1/5] Allow Entra authentication when connecting to Redis --- src/Directory.Packages.props | 5 +-- .../Azure.DataApiBuilder.Service.csproj | 1 + src/Service/Startup.cs | 31 ++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) 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/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..39fc0e29b0 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,33 @@ 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); + + // 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, + }; + + // If no password is provided, and the endpoint (or at least one of them) is non-localhost, + // attempt to use Entra authentication. + if (string.IsNullOrEmpty(options.Password) && !options.EndPoints.Any(IsLocalhostEndpoint)) + { + options = await options.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()); + } + + return await ConnectionMultiplexer.ConnectAsync(options); + } + /// /// Configure GraphQL services within the service collection of the /// request pipeline. From 62d1ceb99087d5b9cb92a701a416f1c62440c927 Mon Sep 17 00:00:00 2001 From: Paul Harrington Date: Tue, 27 Jan 2026 11:33:16 -0800 Subject: [PATCH 2/5] Make localhost logic fit the comment --- src/Service/Startup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 39fc0e29b0..ae7f72b570 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -491,7 +491,7 @@ private static async Task CreateConnectionMultiplexerAsy // If no password is provided, and the endpoint (or at least one of them) is non-localhost, // attempt to use Entra authentication. - if (string.IsNullOrEmpty(options.Password) && !options.EndPoints.Any(IsLocalhostEndpoint)) + if (string.IsNullOrEmpty(options.Password) && options.EndPoints.Any(static ep => !IsLocalhostEndpoint(ep))) { options = await options.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()); } From 3d9bdc9005d4be5183aad31b81c8db1ae17ba9fa Mon Sep 17 00:00:00 2001 From: Paul Harrington Date: Thu, 5 Feb 2026 10:02:38 -0800 Subject: [PATCH 3/5] Add unit tests for determining if Entra auth should be used for Redis based on the connection string. --- src/Service.Tests/UnitTests/StartupTests.cs | 32 +++++++++++++++++++++ src/Service/Startup.cs | 29 +++++++++++++------ 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 src/Service.Tests/UnitTests/StartupTests.cs 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/Startup.cs b/src/Service/Startup.cs index ae7f72b570..b51c7c31ac 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -481,6 +481,25 @@ private static async Task CreateConnectionMultiplexerAsy { 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 { @@ -489,14 +508,8 @@ private static async Task CreateConnectionMultiplexerAsy _ => false, }; - // If no password is provided, and the endpoint (or at least one of them) is non-localhost, - // attempt to use Entra authentication. - if (string.IsNullOrEmpty(options.Password) && options.EndPoints.Any(static ep => !IsLocalhostEndpoint(ep))) - { - options = await options.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()); - } - - return await ConnectionMultiplexer.ConnectAsync(options); + return string.IsNullOrEmpty(options.Password) + && options.EndPoints.Any(ep => !IsLocalhostEndpoint(ep)); } /// From 8beba20a3264447bd07e70c891fb139ca2b204c8 Mon Sep 17 00:00:00 2001 From: Paul Harrington Date: Thu, 5 Feb 2026 17:41:22 -0800 Subject: [PATCH 4/5] Address unit test failure --- .../Authentication/JwtTokenAuthenticationUnitTests.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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")); } /// From 7b90010568dfb94d6ad021cc56bf8408621f8b16 Mon Sep 17 00:00:00 2001 From: Aniruddh Munde Date: Tue, 10 Feb 2026 16:52:20 -0800 Subject: [PATCH 5/5] Fix formatting --- src/Service/Startup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index bce2175d98..e16673347c 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -514,7 +514,7 @@ internal static bool ShouldUseEntraAuthForRedis(ConfigurationOptions options) return string.IsNullOrEmpty(options.Password) && options.EndPoints.Any(ep => !IsLocalhostEndpoint(ep)); } - + /// /// Configures HTTP response compression based on the runtime configuration. /// Compression is applied at the middleware level and supports Gzip and Brotli.