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.