13

I want to respond with a JSON response model when a 401 and 403 occur. For example:

HTTP 401
{
  "message": "Authentication failed. The request must include a valid and non-expired bearer token in the Authorization header."
}

I am using middleware (as suggested in this answer) to intercept 404s and it works great, but it is not the case with 401 or 403s. Here is the middleware:

app.Use(async (context, next) =>
{
    await next();
    if (context.Response.StatusCode == 401)
    {
        context.Response.ContentType = "application/json";
        await context.Response.WriteAsync(JsonConvert.SerializeObject(UnauthorizedModel.Create(), SerializerSettings), Encoding.UTF8);
    }
});

When placed BELOW app.UseJwtBearerAuthentication(..) in Startup.Configure(..), it seems to be completely ignored and a normal 401 is returned.

When placed ABOVE app.UseJwtBearerAuthentication(..) in Startup.Configure(..), then the following exception is thrown:

Connection id "0HKT7SUBPLHEM": An unhandled exception was thrown by the application. System.InvalidOperationException: Headers are read-only, response has already started. at Microsoft.AspNetCore.Server.Kestrel.Internal.Http.FrameHeaders.Microsoft.AspNetCore.Http.IHeaderDictionary.set_Item(String key, StringValues value) at Microsoft.AspNetCore.Http.Internal.DefaultHttpResponse.set_ContentType(String value) at MyProject.Api.Startup.<b__12_0>d.MoveNext() in Startup.cs

Community
  • 1
  • 1
Dave New
  • 38,496
  • 59
  • 215
  • 394

3 Answers3

12

Set was on the right track, but there's actually no need to create your own middleware, as you can leverage the events model to override the default challenge logic.

Here's an example that will return a 401 response containing the OAuth2 error code/description as plain text (you can of course return JSON or whatever you want):

app.UseJwtBearerAuthentication(new JwtBearerOptions
{
    Authority = "http://localhost:54540/",
    Audience = "http://localhost:54540/",
    RequireHttpsMetadata = false,
    Events = new JwtBearerEvents
    {
        OnChallenge = async context =>
        {
            // Override the response status code.
            context.Response.StatusCode = 401;

            // Emit the WWW-Authenticate header.
            context.Response.Headers.Append(
                HeaderNames.WWWAuthenticate,
                context.Options.Challenge);

            if (!string.IsNullOrEmpty(context.Error))
            {
                await context.Response.WriteAsync(context.Error);
            }

            if (!string.IsNullOrEmpty(context.ErrorDescription))
            {
                await context.Response.WriteAsync(context.ErrorDescription);
            }

            context.HandleResponse();
        }
    }
});

Alternatively, you can also use the status code pages middleware, but for 403 responses, you won't have any hint about the authorization policy that caused it:

app.UseStatusCodePages(async context =>
{
    if (context.HttpContext.Request.Path.StartsWithSegments("/api") &&
       (context.HttpContext.Response.StatusCode == 401 ||
        context.HttpContext.Response.StatusCode == 403))
    {
        await context.HttpContext.Response.WriteAsync("Unauthorized request");
    }
});
Kévin Chalet
  • 39,509
  • 7
  • 121
  • 131
  • 1
    Ah, this works well for authentication! Although none of these events seem to trigger for an authorization failure (403). Any idea here? – Dave New Jul 09 '16 at 14:27
  • 1
    Unfortunately, applying the same logic to 403 responses will be a lot trickier (because the `Challenge` event is not called in this case). One option might be to subclass the JWT bearer handler as suggested by @Set and to override `HandleForbiddenAsync` to do the right thing. Hopefully, it will be improved in the next version: https://github.com/aspnet/Security/issues/872. – Kévin Chalet Jul 09 '16 at 14:37
  • That's a pity. Is there a good reason why `app.UseStatusCodePages()` wouldn't be used for 403s? – Dave New Jul 10 '16 at 06:03
  • @davenewza oops, for some reasons, I missed your question, sorry. Actually, you can use the status code pages middleware (I updated my answer), but you have no way to determine what's causing the 403 response, so you can only return a very generic error message, which won't probably be really helpful for the users of your API. – Kévin Chalet Jul 16 '16 at 17:58
  • Is it possible to just return a `IActionResult` instead? – Pedro Moreira Aug 26 '16 at 15:07
  • `app.UseStatusCodePages()` has an overload that allows you to replay the request with a different path. So yes, you could handle that in a controller action and return an `IActionResult`. – Kévin Chalet Aug 26 '16 at 15:22
  • Apparently `IApplicationBuilder` doesn't have a `UseStatusCodePages()` method! – Pedro Moreira Aug 26 '16 at 15:41
  • I ended up using your first approach and customized the Response object manually. Thanks. – Pedro Moreira Aug 26 '16 at 16:35
1

First of all, order of middlewares is important.

Each middleware chooses whether to pass the request on to the next component in the pipeline, and can perform certain actions before and after the next component is invoked in the pipeline

UseJwtBearerAuthentication stops further pipeline execution if error occurred.

But your approach does not work with JwtBearerAuthentication middleware, as when you have unauthorized error, middleware sends WWWAuthenticate header, that why you get "response has already started" exception - look into HandleUnauthorizedAsync method. You can override this method and implement your own custom logic.

Another possible solution (not sure that works) is to use HttpContext.Response.OnStarting callback in your middleware, as it is called before header send. You cal look on this SO answer

Community
  • 1
  • 1
Set
  • 47,577
  • 22
  • 132
  • 150
  • "System.InvalidOperationException: Headers are read-only, response has already started" is thrown if placed before UseJwtBearerAuthentication. – Dave New Jul 09 '16 at 11:08
0

It happens when you write to the httpContext.Response, and call next.Invoke(context), that is where the problem starts: because you already started a response (leading to Response.HasStarted = true), you are not allowed to set the StatusCode anymore. Solution to fix, follow code below:

if (!context.Response.HasStarted)
        {
            try
            {
                await _next.Invoke(context);
            }
        }

Check HasStarted before pass request to next middleware