diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs
index af61e4e..69f0255 100644
--- a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs
+++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs
@@ -313,8 +313,23 @@ public sealed partial class HeimdallIdentityMoveClient(
}
}
-public static class HeimdallIdentityMoveClientServices
+public static class HeimdallClientServices
{
+ ///
+ /// Every Heimdall client Bifrost owns. The auth client carries the app secret, the member client only the member's bearer and
+ /// the mover the app secret and its call deadline; all share the header redaction.
+ ///
+ public static void AddHeimdallClients(this IServiceCollection services)
+ {
+ services.AddHttpClient((serviceProvider, client) =>
+ HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value))
+ .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
+ services.AddHttpClient((serviceProvider, client) =>
+ HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value))
+ .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
+ services.AddHeimdallIdentityMoveClient();
+ }
+
///
/// The mover's HttpClient: Heimdall's base address and app secret from HeimdallAuthService.ConfigureClient (the one secret path), the
/// call deadline, no redirects (a 3xx must never carry the secret or the proofs to another origin), and the shared header redaction.
diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs
index 2985fe2..0a9f48c 100644
--- a/src/Bifrost.Web/Program.cs
+++ b/src/Bifrost.Web/Program.cs
@@ -154,15 +154,8 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddScoped();
-builder.Services.AddHttpClient((serviceProvider, client) =>
- HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value))
- .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
-// Heimdall's member routes take only the member's bearer: this client carries no app secret.
-builder.Services.AddHttpClient((serviceProvider, client) =>
- HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value))
- .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
-// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction, no redirects.
-builder.Services.AddHeimdallIdentityMoveClient();
+// The app-secret and member-bearer clients and the mover: no redirects (see HeimdallClientServices).
+builder.Services.AddHeimdallClients();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs b/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs
new file mode 100644
index 0000000..cce754b
--- /dev/null
+++ b/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs
@@ -0,0 +1,76 @@
+using System.Net;
+using Bifrost.Web.Configuration;
+using Bifrost.Web.Features.Heimdall;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Bifrost.Web.Tests;
+
+///
+/// Every Heimdall client as Program registers it (): a 3xx from Heimdall's origin
+/// is the answer, never a second request. The app secret and a member's bearer reach only the origin they were configured for.
+///
+public sealed class HeimdallRedirectTests
+{
+ private static int FreePort()
+ {
+ var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ [Theory]
+ [InlineData(typeof(HeimdallAuthService), 307)]
+ [InlineData(typeof(HeimdallAuthService), 302)]
+ [InlineData(typeof(HeimdallMemberClient), 307)]
+ [InlineData(typeof(HeimdallMemberClient), 302)]
+ [InlineData(typeof(HeimdallIdentityMoveClient), 307)]
+ public async Task A_redirect_from_Heimdall_is_not_followed_and_no_secret_or_bearer_reaches_the_other_host(Type client, int code)
+ {
+ int first = FreePort(), second = FreePort();
+ using var origin = new HttpListener();
+ origin.Prefixes.Add($"http://127.0.0.1:{first}/");
+ origin.Start();
+ using var other = new HttpListener();
+ other.Prefixes.Add($"http://127.0.0.1:{second}/");
+ other.Start();
+ var redirected = Task.Run(async () =>
+ {
+ var context = await origin.GetContextAsync();
+ context.Response.StatusCode = code;
+ context.Response.RedirectLocation = $"http://127.0.0.1:{second}/steal";
+ context.Response.Close();
+ });
+ var stolen = new List();
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var context = await other.GetContextAsync();
+ stolen.Add(context.Request.Headers["x-heimdall-app-secret"] + context.Request.Headers["Authorization"]);
+ context.Response.StatusCode = 200;
+ context.Response.Close();
+ }
+ catch (Exception error) when (error is HttpListenerException or ObjectDisposedException)
+ {
+ }
+ });
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = $"http://127.0.0.1:{first}", AppSecret = "APP-SECRET-VALUE" }));
+ services.AddHeimdallClients();
+ using var provider = services.BuildServiceProvider();
+ using var http = provider.GetRequiredService().CreateClient(client.Name);
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/v1/me/identities");
+ request.Headers.Authorization = new("Bearer", "MEMBER-BEARER");
+
+ using var response = await http.SendAsync(request);
+ await redirected;
+ await Task.Delay(300);
+
+ Assert.Equal(code, (int)response.StatusCode);
+ Assert.Empty(stolen);
+ }
+}