diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
index 98d35646d..dd9cda737 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
@@ -16,6 +16,13 @@ public class SidecarOptions
/// ignored on that route and a warning is logged.
///
public AllowOverridesOptions AllowOverrides { get; set; } = new();
+
+ ///
+ /// Gets or sets a value indicating whether the sidecar's outbound
+ /// follows HTTP redirect (3xx) responses
+ /// from downstream APIs. Defaults to false.
+ ///
+ public bool AllowOutboundRedirects { get; set; }
}
///
diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs
index 8787654a4..7fad0e589 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Program.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs
@@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Options;
using Microsoft.Identity.Web.Sidecar.Configuration;
using Microsoft.Identity.Web.Sidecar.Endpoints;
using Microsoft.IdentityModel.JsonWebTokens;
@@ -46,6 +47,14 @@ public static void Main(string[] args)
builder.Services.Configure(builder.Configuration.GetSection("Sidecar"));
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ http.ConfigurePrimaryHttpMessageHandler(serviceProvider =>
+ new SocketsHttpHandler
+ {
+ AllowAutoRedirect = serviceProvider.GetRequiredService>()
+ .Value.AllowOutboundRedirects,
+ }));
+
builder.Services.AddHealthChecks();
ConfigureAuthN(builder);
diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md
index 4b621911b..b1996f31c 100644
--- a/src/Microsoft.Identity.Web.Sidecar/README.md
+++ b/src/Microsoft.Identity.Web.Sidecar/README.md
@@ -48,6 +48,24 @@ Settings are supplied via `appsettings.json`, environment variables, or any stan
- **AzureAd**: Standard Microsoft.Identity.Web web API registration; client credentials are optional if only delegated flows are required.
- **DownstreamApis**: Named profiles for endpoints resolved via `{apiName}`.
+### Outbound redirects
+
+`Sidecar:AllowOutboundRedirects` controls whether the sidecar's outbound `HttpClient`
+follows HTTP redirect (3xx) responses returned by a downstream API. It defaults to
+`false`, so a redirect response is returned to the caller as-is rather than followed.
+Set it to `true` to opt in to following redirects.
+
+> **Note:** Outbound redirects are not followed by default (`AllowOutboundRedirects`
+> defaults to `false`). To follow redirects, set it to `true`:
+>
+> ```jsonc
+> {
+> "Sidecar": {
+> "AllowOutboundRedirects": true
+> }
+> }
+> ```
+
## Running the sidecar
### Prerequisites
diff --git a/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs b/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs
new file mode 100644
index 000000000..580eb2be4
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs
@@ -0,0 +1,237 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Net.Sockets;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Abstractions;
+using Microsoft.Identity.Web.Sidecar.Models;
+using Moq;
+using Xunit;
+
+namespace Sidecar.Tests;
+
+public class OutboundRedirectTests
+{
+ [Fact]
+ public async Task OutboundRedirect_NotFollowedByDefault_ReturnsRedirectAndTargetNotContacted()
+ {
+ // Arrange
+ const string apiName = "redirect-api";
+
+ using var redirectTarget = new RawHttpServer(_ =>
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK");
+ string redirectTargetUrl = $"http://127.0.0.1:{redirectTarget.Port}/next";
+
+ using var configured = new RawHttpServer(_ =>
+ $"HTTP/1.1 302 Found\r\nLocation: {redirectTargetUrl}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+
+ var headerProvider = CreateHeaderProviderMock();
+
+ using var factory = new SidecarApiFactory();
+ var client = factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ services.AddSingleton(headerProvider.Object);
+ services.Configure(apiName, o =>
+ {
+ o.BaseUrl = $"http://127.0.0.1:{configured.Port}";
+ o.RelativePath = "/start";
+ o.HttpMethod = HttpMethod.Get.Method;
+ o.RequestAppToken = true;
+ o.Scopes = ["api.default"];
+ });
+ });
+ }).CreateClient();
+
+ // Act
+ var response = await client.PostAsync($"/DownstreamApiUnauthenticated/{apiName}", content: null);
+ var body = await response.Content.ReadFromJsonAsync();
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(body);
+ Assert.Equal(302, body!.StatusCode);
+ Assert.True(configured.HitCount >= 1);
+ Assert.Equal(0, redirectTarget.HitCount);
+ }
+
+ [Fact]
+ public async Task OutboundRedirect_FollowedWhenOptedIn_TargetContacted()
+ {
+ // Arrange
+ const string apiName = "redirect-api";
+
+ using var redirectTarget = new RawHttpServer(_ =>
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK");
+ string redirectTargetUrl = $"http://127.0.0.1:{redirectTarget.Port}/next";
+
+ using var configured = new RawHttpServer(_ =>
+ $"HTTP/1.1 302 Found\r\nLocation: {redirectTargetUrl}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+
+ var headerProvider = CreateHeaderProviderMock();
+
+ using var factory = new SidecarApiFactory();
+ var client = factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Sidecar:AllowOutboundRedirects", "true" },
+ });
+ });
+
+ builder.ConfigureServices(services =>
+ {
+ services.AddSingleton(headerProvider.Object);
+ services.Configure(apiName, o =>
+ {
+ o.BaseUrl = $"http://127.0.0.1:{configured.Port}";
+ o.RelativePath = "/start";
+ o.HttpMethod = HttpMethod.Get.Method;
+ o.RequestAppToken = true;
+ o.Scopes = ["api.default"];
+ });
+ });
+ }).CreateClient();
+
+ // Act
+ var response = await client.PostAsync($"/DownstreamApiUnauthenticated/{apiName}", content: null);
+ var body = await response.Content.ReadFromJsonAsync();
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(body);
+ Assert.Equal(200, body!.StatusCode);
+ Assert.True(configured.HitCount >= 1);
+ Assert.True(redirectTarget.HitCount >= 1);
+ }
+
+ // Sidecar supports bearer tokens only. To add mTLS, also set AllowAutoRedirect = false
+ // on the mTLS client.
+ [Fact]
+ public async Task Sidecar_DownstreamCalls_UseDefaultHttpClientFactoryPath()
+ {
+ // Arrange
+ const string apiName = "guard-api";
+
+ using var factory = new SidecarApiFactory();
+ using var host = factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ services.Configure(apiName, o =>
+ {
+ o.BaseUrl = "https://downstream.example";
+ o.Scopes = ["api.default"];
+ });
+ });
+ });
+
+ using var scope = host.Services.CreateScope();
+ var provider = scope.ServiceProvider;
+
+ // Act
+ var schemes = (await provider.GetRequiredService()
+ .GetAllSchemesAsync()).ToList();
+ var defaultScheme = await provider.GetRequiredService()
+ .GetDefaultAuthenticateSchemeAsync();
+ var options = provider.GetRequiredService>().Get(apiName);
+
+ // Assert
+ Assert.All(schemes, s => Assert.Equal(JwtBearerDefaults.AuthenticationScheme, s.Name));
+ Assert.Equal(JwtBearerDefaults.AuthenticationScheme, defaultScheme?.Name);
+ Assert.NotEqual("MTLS", options.ProtocolScheme, StringComparer.OrdinalIgnoreCase);
+ Assert.NotEqual("MTLS_POP", options.ProtocolScheme, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static Mock CreateHeaderProviderMock()
+ {
+ var mock = new Mock();
+ mock.Setup(p => p.CreateAuthorizationHeaderAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync("Bearer test-token");
+ return mock;
+ }
+
+ private sealed class RawHttpServer : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly Func _responder;
+ private readonly CancellationTokenSource _cts = new();
+ private int _hitCount;
+
+ public RawHttpServer(Func responder)
+ {
+ _responder = responder;
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+ _ = Task.Run(AcceptLoopAsync);
+ }
+
+ public int Port { get; }
+
+ public int HitCount => Volatile.Read(ref _hitCount);
+
+ private async Task AcceptLoopAsync()
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ TcpClient tcp;
+ try
+ {
+ tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
+ }
+ catch
+ {
+ break;
+ }
+
+ _ = Task.Run(() => HandleAsync(tcp));
+ }
+ }
+
+ private async Task HandleAsync(TcpClient tcp)
+ {
+ try
+ {
+ using (tcp)
+ await using (var stream = tcp.GetStream())
+ {
+ var buffer = new byte[8192];
+ int read = await stream.ReadAsync(buffer);
+ string requestText = Encoding.ASCII.GetString(buffer, 0, read);
+ Interlocked.Increment(ref _hitCount);
+
+ byte[] responseBytes = Encoding.ASCII.GetBytes(_responder(requestText));
+ await stream.WriteAsync(responseBytes);
+ await stream.FlushAsync();
+ }
+ }
+ catch
+ {
+ // Best-effort test listener.
+ }
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _listener.Stop();
+ _cts.Dispose();
+ }
+ }
+}