Search code examples
asp.net.netiisws-federation

WsFederation using a local URL instead of login.microsoftonline.com


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:

  • Our login flow relies on a 302 redirect to the IDP;
  • Application Request Routing provides the front-line reverse proxy for application servers.

Additional troubleshooting that led me to the solution:

Logging the 302 the application actually returns

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.


Solution

  • 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.