I have a .NET 8.0 app that uses WsFederation. On one server this works well; on another, not at all. Identical binaries and identical configs produce different results and I am out of ideas why.
When logging in on the healthy server, an endpoint in my app returns a new ChallengeResult
, which produces a 302 response pointing login.microsoftonline.com/[our tenant ID]/wsfed?[and a relevant query string]. The unhealthy server generates a 302 pointing to [our own domain]/[our tenant ID]/[the rest].
Both servers have outgoing access to the internet; I've confirmed I can access the MetadataAddress for the AAD config.
I'm sure it's relevant that the unhealthy server is running behind a pair of reverse proxies: the first is our public gateway and uses a simple URL Rewrite rule to reach the app server. The app server uses an IIS Server Farm to route requests to individual processes.
I have been attempting to set X-Forwarded-Host and HTTP_HOST in the server farm's rewrite rules; this is not working but it's a shot in the dark on my part anyway.
services.AddAuthentication()
.AddWsFederation(options =>
{
// MetadataAddress represents the Active Directory instance used to authenticate users.
options.MetadataAddress = Configuration["AAD:MetadataAddress"];
options.Wtrealm = Configuration["AAD:Wtrealm"];
options.Wreply = Configuration["AAD:Wreply"];
options.CallbackPath = new PathString("/signin-wsfed");
options.AllowUnsolicitedLogins = true;
})
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
});
[AllowAnonymous]
public IActionResult InternalLogin([FromQuery(Name = "returnUrl")] string returnUrl = null)
{
try
{
string provider = "WsFederation";
var redirectUrl = Url.Action(nameof(InternalLoginCallback), "UserAccount", new { ReturnUrl = returnUrl });
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
_logService.LogInternalSignInRequest();
using var log = new LoggerConfiguration().WriteTo.File("logs/serilog.txt").CreateLogger();
// The following always displays the name of the server farm, despite best efforts to set the Host (and X-Forwarded-Host) header myself
log.Information("DURING INTERNAL LOGIN, provider: {p}, redirect URL: {u}, properties: {pt}", provider, redirectUrl, properties);
return new ChallengeResult(provider, properties);
}
catch (Exception e)
{
return BadRequest("Failed");
}
}
I would expect the ChallengeResult to produce the same 302 on both servers, in this case pointing to login.microsoftonline.com, as specified in the results of a call to https://login.microsoftonline.com/[our tenant ID]/federationmetadata/2007-06/federationmetadata.xml?appid=[our app ID]. Since I can retrieve the same result from my own machine in a browser as I can from either of the servers, I'm reluctant to believe the host header is actually relevant here.
What would cause this process to send a 302 to a local address, instead of the IDP in the metadata?
EDIT:
I've solved this after wandering around some blind alleys a while. My initial case was very specific, but the cause is more general than I would have expected; I'm editing to capture the relevant details for anyone else who runs across this in the future.
The salient details of the problem are:
Additional troubleshooting that led me to the solution:
As usual, a long bout of troubleshooting in this line of work is about dissecting assumptions and looking into the dark cracks of ignorance underneath. In my case, I was seeing the browser receive a 302 pointing to my own domain and assumed that's what the application was returning. As a lemma to understanding my mistake, I used this same method to log the exact headers the app received from requests to different layers of the proxy - all identical!
To prove to myself the app was therefore returning identical results, I added a custom middleware that would watch for 302s being returned to the client and log their Location headers to a file.
public class RedirectLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly Logger _logger;
public RedirectLoggingMiddleware(RequestDelegate next, ILogger<RedirectLoggingMiddleware> logger)
{
_next = next;
// I'm really starting to like Serilog!
_logger = new Serilog.LoggerConfiguration().WriteTo.Console().CreateLogger();
}
public async Task Invoke(HttpContext context)
{
await _next(context); // Call the next middleware
if (context.Response.StatusCode == 302)
{
// Log the redirect
var location = context.Response.Headers["Location"].ToString();
_logger.Information("302 Redirect to {Location} from {Path}", location, context.Request.Path);
}
}
}
In Startup.cs:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Register your middleware early in the pipeline
app.UseMiddleware<RedirectLoggingMiddleware>();
// ... other middleware registrations
}
This confirmed that identical input garnered identical output: nothing in the environment itself changed for different origins of the request.
This left only one place to look downstream: the Application Request Router.
Following all the diagnostics above, I could finally take a long and close look at the ARR configuration. Things have moved from what you may find in setup guides: there is no longer an Application Request Routing tile in the server node pane in IIS, but only Application Request Routing Cache. This seems like a strange bit of UX, naming the top-line entry-point for a specific sub-feature of the thing we're configuring, but hey, it's Microsoft's world. I just work here.
Within that tile, in the Actions bar on the right is a link "Server Proxy Settings." Just below "Time-out (seconds)" is a checkbox for "Reverse rewrite host in response headers."
In my wildest dreams I never imagined this would rewrite any host, but that appears to be the functionality. When I turned this feature off, the 302 suddenly pointed correctly to login.microsoftonline.com again; ARR had been replacing that host with my own.
First to admit my own ignorance, this behavior was extremely unexpected to me, and it was on by default. I had imagined, when I saw the option, that it would rewrite only the host it was forwarding requests to to match the one it had received the request for. Live and learn.