`[AllowAnonymous]` ignored after request was handled by YARP
Discussed in https://github.com/microsoft/reverse-proxy/discussions/2554
Originally posted by johanndev July 18, 2024 Scenario: I have an ASP.NET Core Project that uses Yarp as an ingress for serveral backends and a JavaScript/SPA Frontend. Authentication is handled via a on-premise ADFS instance/OIDC. As the ADFS services a few different applications and not all authenticated users will get access to my app, I want the default Authorization Policy to include a RoleRequirement:
builder.Services.AddAuthorizationBuilder()
.SetDefaultPolicy(
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireRole("MyRole")
.Build()
);
Users without this role get automatically redirected to /Account/AccessDenied handled by this Controller located in the same API that contains the reverse proxy:
[AllowAnonymous]
public class AccountController() : Controller
{
[HttpGet]
public IResult AccessDenied(string returnUrl)
{
return TypedResults.Ok($"AccessDenied\n{returnUrl}");
}
}
When a user without the required Role is successfully authenticated, authorization fails as expected and the user is redirected to the /Account/AccessDenied route. The problem is that the role policy is evaluated again, even though the AccountController is annotated with the [AllowAnonymous] attribute. As the policy evaluation fails again, an infinite loop is triggered.
This is the relevant section from the logs:
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/2 GET https://localhost:7018/ - - -
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
Authorization failed. These requirements were not met:
RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (MyRole)
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished HTTP/2 GET https://localhost:7018/ - 302 0 - 13.8438ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/2 GET https://localhost:7018/Account/AccessDenied?ReturnUrl=%2F - - -
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
Authorization failed. These requirements were not met:
RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (MyRole)
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished HTTP/2 GET https://localhost:7018/Account/AccessDenied?ReturnUrl=%2F - 302 0 - 12.4166ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/2 GET https://localhost:7018/Account/AccessDenied?ReturnUrl=%2FAccount%2FAccessDenied%3FReturnUrl%3D%252F - - -
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
Authorization failed. These requirements were not met:
RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (MyRole)
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished HTTP/2 GET https://localhost:7018/Account/AccessDenied?ReturnUrl=%2FAccount%2FAccessDenied%3FReturnUrl%3D%252F - 302 0 - 7.1027ms
....Repeat until the browser calls quits with ERR_TOO_MANY_REDIRECTS
When I remove the reverse proxy from the middleware pipeline, everything works as expected.
It seems that if a request was first handled by Yarp, the default authorization policy handling is somehow circumvented.
Does anyone have an idea how I can tackle this problem? I'm open to alternative approaches.
Thanks!
Can you share the config or even a small repro?
I'm trying to build a repro, in the meantime here are excerpts of the relevant configurations:
appsettings:
"ReverseProxy": {
"Routes": {
"host": {
"ClusterId": "host",
"AuthorizationPolicy": "default",
"Match": {
"Path": "{**catch-all}"
}
},
}
},
Services:
builder.Services.AddControllers();
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")):
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(cfg =>
{
cfg.MapInboundClaims = false;
cfg.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
cfg.ResponseType = "code token id_token";
cfg.Authority = authConfig.Authority;
cfg.ClientId = authConfig.ClientId;
cfg.UseTokenLifetime = true;
cfg.UsePkce = true;
cfg.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
RoleClaimType = "role",
NameClaimType = "upn"
};
});
Middleware Pipeline:
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.MapReverseProxy();
@benjaminpetit I created a reproduction sample here: https://github.com/johanndev/yarp-2560-repro
Unfortunately, I wasn't able to build a fully standalone sample - To test the setup, an ADFS instance is needed (See Program.cs L21-22). Let me know if the code is of some help, otherwise I will try to come up with a sample that uses a mocked user or something along those lines.
Some findings:
When I set a breakpoint in the authorization middleware at the point where AllowAnonymous is evaluated, I see that the corresponding Metadata is not available:
The request path is mapped to the controller ("/Account/AccessDenied"), but somehow it still seems to be handled by YARP.
That's showing that the first request was redirected to /Account/AccessDeined, but the second (that followed the redirect) still matched /Account/AccessDenied to YARP's catch-all route. This is likely an issue of route priority. Do you get the same issue if you navigate directly to /Account/AccessDenied?
The more specific route should have matched, but you could always override YARP's route priority by setting "Order" : 10000, Lower numbers have higher precedence
Do you get the same issue if you navigate directly to /Account/AccessDenied?
Yes, navigating to /Account/AccessDenied directly also triggers a redirect to ADFS and ultimately leads to the redirect loop.
I tried your suggestion:
"route1": {
"ClusterId": "cluster1",
"AuthorizationPolicy": "default",
"Match": {
"Path": "{**catch-all}"
},
"Order": 10000
}
...but the issue persists.
Skimming the routing docs lead me this warning (At the bottom of this section: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-8.0#url-matching):
Endpoint routing in ASP.NET Core:
Doesn't have the concept of routes. Doesn't provide ordering guarantees. All endpoints are processed at once.
I suppose this applies to YARP as well?
Is it possible to log the routing precedence (i.e., the "Order" property) for all endpoints to confirm it's the reason for the observed behavior?
I just pushed an update to my reproduction sample (https://github.com/johanndev/yarp-2560-repro), it is now fully standalone and can be used without further config.
Some more investigation:
I created a debug endpoint that logs route information:
As you can see, the order property is set but seemingly doesn't influence the endpoint precedence of the controller route.
But as I was looking at the debug info it dawned on me: The debug endpoint is reachable and doesn't exhibit the same behavior as the controller route. And lo and behold, if I remove the controller and add the route to via an minimal api:
app.MapGet("/Account/AccessDenied", (string returnUrl = "<empty>") => {
return $"AccessDenied, returnUrl: {returnUrl}";
});
...everything works as expected :)
This unblocks me for now (at least in the reproduction), but begs the question if my scenario is possible with controllers or if users are forced to got the minimal API route.
Has this been answered with https://x.com/Hackysack/status/1825878563913937166?
Hi,
It fixed the error in my reproduction, but unfortunately I wasn't able to get it working in my real application. For the moment, I will go with a minimal route to unblock the project. The setup works, as proven by the reproduction, so the error has to originate at some other place.
I will close this issue and open another one if necessary.
Thanks for the support!