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:
- Configure the
ForwardedHeaderOptions
to useX-Forwarded
headers. - Add
Cookies
authentication and the sharedDataProtectionProvider
. - 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.