InvalidOperationException when using JwtBearerEvents.OnForbidden to customize response and multiple AuthenticationSchemes
See original GitHub issueDescribe the bug
Our project has a requirement that all error responses, including those related to authentication, include a standard error object in the response body. In order to satisfy this requirement, we implement a couple of JwtBearerEvents
events as follows (tokenType
is defined somewhere else):
new JwtBearerEvents {
OnChallenge = async context => {
if (!context.Response.HasStarted) {
// This can fire if the Authorization header is missing or malformed, handle those differently.
var err = !string.IsNullOrEmpty(context.Request.Headers["Authorization"])
? new ServiceError(ErrorCode.INVALID_AUTHORIZATION, $"An invalid {tokenType} was included in the request.")
: new ServiceError(ErrorCode.MISSING_AUTHORIZATION, $"An {tokenType} must be included in the request.");
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = MediaTypeNames.Application.Json;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(err)));
}
context.HandleResponse();
},
OnForbidden = async context => {
var err = new ServiceError(ErrorCode.INVALID_AUTHORIZATION, $"An invalid {tokenType} was included in the request.");
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = MediaTypeNames.Application.Json;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(err)));
}
}
This generally works great, and does exactly what we need. However, we’ve discovered that this causes issues for one very specific case: when an action’s AuthorizeAttribute
has multiple schemes, for example [Authorize(AuthenticationSchemes = "FooScheme,BarScheme", Policy = "SomePolicy")]
, and an authentication attempt is denied by the specified Policy.
When this happens, the OnForbidden
event is invoked once, for the first scheme (“FooScheme” here), and then the entire request fails with the following (somewhat trimmed):
Message:
System.InvalidOperationException : The status code cannot be set, the response has already started.
Stack Trace:
ResponseFeature.set_StatusCode(Int32 value)
DefaultHttpResponse.set_StatusCode(Int32 value)
JwtBearerHandler.HandleForbiddenAsync(AuthenticationProperties properties)
AuthenticationHandler`1.ForbidAsync(AuthenticationProperties properties)
AuthenticationService.ForbidAsync(HttpContext context, String scheme, AuthenticationProperties properties)
AuthorizationMiddlewareResultHandler.HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
AuthorizationMiddleware.Invoke(HttpContext context)
AuthenticationMiddleware.Invoke(HttpContext context)
DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
<<SendAsync>g__RunRequestAsync|0>d.MoveNext()
After digging into the code mentioned in the stack trace using the debugger, I’ve determined that the issue seems to be a combination of two things:
- First, in
AuthorizationMiddlewareResultHandler.HandleAsync()
, the “else if” branch has logic that will invokecontext.ForbidAsync()
for all schemes, if there are multiple. - Second, and more importantly, is what
JwtBearerHandler
is doing here:
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
var forbiddenContext = new ForbiddenContext(Context, Scheme, Options);
Response.StatusCode = 403;
return Events.Forbidden(forbiddenContext);
}
It’s clear to me that the reason I’m getting the exception described above is because, when AuthorizationMiddlewareResultHandler.HandleAsync()
calls context.ForbidAsync()
for the second scheme, the response body has already been written by the first invocation of our OnForbidden
event implementation…which means that JwtBearerHandler.HandleForbiddenAsync()
is going to fail since it’s trying to set StatusCode again.
Compared to the logic used by JwtBearerHandler.HandleChallengeAsync()
, the implementation of HandleForbiddenAsync()
seems rather…lackluster. It seems to me that it would be helpful if HandleForbiddenAsync()
and the ForbiddenContext
object would expose a mechanism similar to that used by HandleChallengeAsync()
/JwtBearerChallengeContext
, whereby the OnForbidden
event implementation could have a chance to call context.ResponseHandled()
, which in turn would signal to the HandleForbiddenAsync()
method that it should not attempt to do anything to the response. This would allow us to check context.Response.HasStarted
in our event implementation and do nothing (both in the event, and in HandleForbiddenAsync()
) if it returns true.
Could such a mechanism be added? The only alternative I’ve been able to find for our project is to not use multiple schemes in a single AuthorizeAttribute
, but since going that route would require us to create a separate action (and thus request path) for every affected action…it’s definitely not desirable.
PS: Apologies if this doesn’t quite fit the usual “Bug Report” bucket; I know it’s somewhere between that and a feature request, but felt it leaned more towards the bug side of the spectrum.
Further technical details
- ASP.NET Core 5.0.300
- Microsoft.AspNetCore.Authentication.JwtBearer 5.0.6
- Visual Studio 2019 (16.10.0)
Issue Analytics
- State:
- Created 2 years ago
- Comments:11 (10 by maintainers)
@wcontayon Just checking, are you still interested in working on this?
Hello @Tratcher , I submitted a PR with the requested change since it looks like this issue is still open and it has been a couple years since the last community comments. I hope this is okay. Please let me know if you would like me to make any changes to my PR here https://github.com/dotnet/aspnetcore/pull/47089. Thanks
Here was the requested change for reference, based on the comment on 6/17/21.
HandleForbiddenAsync could check Response.HasStarted before setting the status code. If the response had started it would log instead. The event logic would then run as normal.