I was recently reading Mike Hadlow’s blog post titled “Writing .NET Applciation Services for Kubernetes,” and it had me reflecting on my knowledge, or lack thereof, of deploying .NET applications into a distributed environment like Kubernetes. His section on Security stood out as the “opposite” to how many .NET devs may build and deploy their applications. Many ASP.NET Core apps have authentication, HTTPS, and security built-in.

Build your services to serve plain unencrypted HTTP, do HTTPS termination and authentication on your ingress reverse proxy. – Mike Hadlow

That recommendation makes sense considering your apps should exist in a close network. Whether you agree with the idea, I found it interesting enough to research the clawing question: “How do you flow ASP.NET Core authentication to downstream apps when using a reverse proxy?”

Well, let’s find out. This post details the steps you might take to flow credentials down from your proxy to each downstream component. I will also note that this post is not a “best practice” but more of a discovery of my findings. Please consult a security specialist to determine if this solution is right for you: I recommend Dominick Baier and Brock Allen, co-founders of Duende Software.

The Basic Infrastructure

We have three ASP.NET Core projects for the demo solution: Proxy, IdentityServer Auth, and Backend. Each of these applications plays a critical role in our solution, and you can expand the solution to include many more projects once you understand the mechanisms.

Technologies used in the solution include Yet Another Reverse Proxy (YARP), Duende Software IdentityServer, and ASP.NET Core middlewares.

By the end of this post, we’ll have YARP dependent on IdentityServer, logging users into our system, managing cookies we can share across the entire infrastructure, and then successfully logging out.

It’s going to get dense, so if you want to see the solution running, I recommend going to my GitHub repository and cloning it.

The YARP Proxy Project

YARP is very powerful, but its documentation can be challenging to navigate. While I present a solution that may seem straightforward at first, be aware that this took more time than I wish to have spent on it.

You’ll do most of your YARP’s configuration in the appSettings.json of an ASP.NET Core project in a section named ReverseProxy. Here you can set up route rules and clusters. Routes are similar to the routes you may define in ASP.NET Core, but with more features like multiple matching rule sets and transforms as the incoming request gets mapped to a cluster. Let’s start by exploring the IdentityServer route and cluster.

Let’s start with the clusters pointing to our backend and authentication apps. Our proxy will be listening at https://localhost:7291, so you should never need to call these other host URLs directly.

"Clusters": {
  "app": {
    "Destinations": {
      "app/destination": {
        "Address": "https://localhost:7008/"
      }
    }
  },
  "identity": {
    "Destinations": {
      "identity/destination": {
        "Address": "https://localhost:5001/"
      }
    }
  }
}

Let’s look at the Auth route under the Routes section.

"identity" : {
  "ClusterId" : "identity",
  "Match" : {
    "Hosts" : [ "localhost" ],
    "Path" : "/auth/{**remainder}"
  },
  "Transforms" : [
    { "PathRemovePrefix": "/auth" },
    { "X-Forwarded":  "Set"},
    {
      "RequestHeader": "X-Forwarded-Prefix",
      "Set": "/auth"
    }
  ] 
}

The route matches on the following request criteria:

  • The incoming host must be localhost.
  • The path of the request starts with /auth.

When the route matches, we need YARP to transform the request so our downstream app can recognize the request.

  • Remove the /auth prefix because the downstream app doesn’t care.
  • Set all the X-Forwarded headers. These headers allow our downstream app to get vital information to mimic the user’s expected behavior.
  • Set a custom X-Forwarded-Prefix header. The prefix is essential to get IdentityServer generating correct URIs.

Great! That was easy, right? What about our other backend application?

"app" : {
  "ClusterId": "app",
  "AuthorizationPolicy": "auth",
  "Match": {
    "Hosts": [ "localhost" ],
    "Path" : "/app/{**remainder}"
  },
  "Transforms" : [
    { "PathRemovePrefix": "/app" },
    { "X-Forwarded":  "Set"},
    {
      "RequestHeader": "X-Forwarded-Prefix",
      "Set": "/app"
    }
  ]
},

Well, it’s very similar to our identity route, except this one depends on an AuthorizationPolicy of auth. You can define the authorization policy in the hosting ASP.NET Core project right after adding and configuring the reverse proxy.

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

builder.Services.AddAuthorization(o =>
{
    o.AddPolicy("auth", p => p.RequireAuthenticatedUser());
});

The authorization policy forces any incoming request to trigger the Auth of the host. Let’s define a cookie policy and OpenID Connect authentication provider.

builder.Services.AddAuthentication(o =>
{
    o.DefaultScheme = "Cookies";
    o.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies", o =>
{
    o.Cookie.Name = ".myapp";
    o.DataProtectionProvider = DataProtectionProvider.Create("yarp-test");
})
.AddOpenIdConnect("oidc", o =>
{
    // proxy url
    o.Authority = "https://localhost:7291/auth";

    o.ClientId = "interactive";
    // copied from Identity project
    o.ClientSecret = "49C1A7E1-0C79-4A89-A3D6-A37998FB86B0";
    o.ResponseType = "code";

    o.SaveTokens = true;

    o.Scope.Clear();
    o.Scope.Add("openid");
    o.Scope.Add("profile");
    o.GetClaimsFromUserInfoEndpoint = true;
});

The essential part of this configuration is the call to AddCookie. We need to make the cookie’s name consistent across all our proxied apps and allow all apps to have a standard DataProtectionProvider. The DataProtectionProvider will enable apps to decrypt and access the information in the cookies. Note: There are different ways to share DataProtectionProvider information, including saving critical data to a database.

You should also note that our Authority URL is pointing to our proxy, then passed down to our authentication app.

Now we’re ready to set up the proxy’s request handling pipeline. I’ve added a few endpoints to test our authentication later.

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", () => "Hi, try going to /auth or /app");
app.MapGet("/proxy/has-user", (ClaimsPrincipal user) => user.GetName())
    .RequireAuthorization();

app.MapReverseProxy();

app.Run();

We’re ready to set up our auth app.

Setting up IdentityServer

For this sample, I used the Duende IdentityServer with In-Memory Stores and Test Users template. You can get the template by installing all the Duende templates using the following command.

dotnet new --install Duende.IdentityServer.Templates

Then create the same project using the isinmem template.

dotnet new isinmem -o Identity

You can also use your favorite IDE to create a new project as part of an existing solution. This project includes UI for logging in to the apps and an important HostingExtensions file configuring the app’s services and request handling pipeline.

The first thing we’ll need to add is the configuration of our ForwardedHeaderOptions instance. A middleware will use this to change the host name of any generated links, including links generated by IdentityServer. Under ConfigureServices, let’s add a Configure call.

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.All;
    
    options.KnownNetworks.Clear();
    options.KnownProxies.Clear();
});

We can use the value of ForwardedHeaders.All since we’re behind the proxy. You can also experiment with these flags depending on your use case.

Our next step is to add Cookies as an authentication option to IdentityServer. Technically, IdentityServer won’t be writing the same cookie at this path, but this lets ASP.NET Core delete our auth cookies later when we sign out. Note that you’re using the same DataProtectionProvider. By default, ASP.NET Core stores keys on disk; since we’re running our app locally, every app has access to the same folder.

.AddCookie("Cookies", o =>
{
    o.Cookie.Path = "/";
    o.Cookie.Name = ".myapp";
    o.DataProtectionProvider = DataProtectionProvider.Create("yarp-test");
})

Finally, let’s handle that pesky X-Forwarded headers. Handling these header values is critical to generating the right URIs. Without this addition to the request pipeline, you will break everything, trust me.

app.UseForwardedHeaders();
app.Use((context, next) =>
{
    if (context.Request.Headers.TryGetValue("X-Forwarded-Prefix", out var pathBase))
    {
        context.Request.PathBase = pathBase.ToString();
    }
    return next();
});

Since X-Forwarded-Prefix is a custom header, we need to handle it separately. This code is a good candidate for you to refactor into a reusable piece of middleware.

Before we leave this project, we need to do one more thing. We must remove all cookies when we sign out of our auth route. Using the IdentityServer sample, you can navigate to Pages/Account/Logout/Index.cshtml.cs. Here, place the following line under the existing await HttpContext.SignOutAsync() call.

// remove other cookies
await HttpContext.SignOutAsync("Cookies");

This additional call to SignOutAsync will clear the cookies used by the other services and effectively sign you out of everything.

The Backend App

Now, let’s set up our backend app. If you’ve made it this far in the post, you’ll likely think this looks very familiar because it is! Let’s look at the entire backend app of this sample.

using System.Security.Claims;
using Duende.IdentityServer.Extensions;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.All;

    options.KnownNetworks.Clear();
    options.KnownProxies.Clear();
});
builder.Services.AddAuthorization(o => { o.AddPolicy("auth", p => p.RequireAuthenticatedUser()); });

builder.Services.AddAuthentication(o => { o.DefaultScheme = "Cookies"; })
    .AddCookie("Cookies", o =>
    {
        o.Cookie.Name = ".myapp";
        o.DataProtectionProvider = DataProtectionProvider.Create("yarp-test");
    });

var app = builder.Build();

app.UseForwardedHeaders();
app.Use((context, next) =>
{
    if (context.Request.Headers.TryGetValue("X-Forwarded-Prefix", out var pathBase))
    {
        context.Request.PathBase = pathBase.ToString();
    }

    return next();
});

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user, HttpContext ctx) =>
new
{
    name = user.GetName(),
    claims = user.Claims.Select(x => new { name = x.Type, value = x.Value }),
    host = ctx.Request.Host
})
.RequireAuthorization("auth");

app.Run();

We need to do a similar set-up to our authentication app:

  1. Configure the ForwardedHeaderOptions to use X-Forwarded headers.
  2. Add Cookies authentication and the shared DataProtectionProvider.
  3. Add the Middlewares to handle the X-Forwarded headers.

You may notice that the configuration is missing any reference to OpenID connect. In our case, the proxy is responsible for communicating with IdentityServer. Our downstream app should only ever see the cookies. The drawback here is if the cookie expires, you’ll need to redirect back to an endpoint on the proxy to trigger a re-login.

The Result of All This Configuration

Ugh, what’s the point of this exercise? Our proxy can now trigger authentication requirements while sharing the results downstream. So while you distribute the app’s functionality, the authentication still behaves like you’re working in a monolith. In-app code works like any regularly hosted ASP.NET Core application with access to ClaimsPrincipal, link generation, and much more.

I will note that this post was more of an experiment than a recommendation. As I mentioned at the beginning, please consult a security professional and see the drawbacks of an approach like the one discussed here.

If you want the complete solution, I recommend going to my GitHub repository, cloning it, and playing around..

Mike mentioned in his original post that there are other solutions where authentication is done purely on the proxy itself, and the proxy passes user information via headers. These headers can be transformed into a ClaimsPrincipal and provide similar development transparency.

I hope you enjoyed this blog post, and let me know if you think there are any issues with it by pinging me on Twitter, @buhakmeh.