json-api-dotnet / JsonApiDotNetCore

A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core.

Home Page:https://www.jsonapi.net

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

How to write a json:api exception in an authentication middleware

TheoLevalet opened this issue · comments

SUMMARY

Hello, I am trying to write authentication error message on an extension if a user is not connected. It works but all my requests containing filters or including fields throw a 500 error. Is there a way to fix it?

DETAILS

In my AuthenticationHandler, I have override the HandleChallengeAsync method as follow and achieve the writing of the error thanks to the IJsonApiWriter.

        protected override Task HandleChallengeAsync(AuthenticationProperties properties) {
            IReadOnlyList<ErrorObject> errors = _exceptionHandler.HandleException(new UnauthorizedException());

            Response.StatusCode = (int)ErrorObject.GetResponseStatusCode(errors);
            _jsonApiWriter.WriteAsync(errors, _httpContextAccessor.HttpContext!);

            return Task.CompletedTask;
        }

At first it seems to work, but the IJsonApiWriter injection crashed all of the filter and include requests with following log:

Category: JsonApiDotNetCore.Middleware.ExceptionHandler EventId: 0 SpanId: |f642c290-463530373b9b6be2.f642c291_bbba9462_ TraceId: f642c290-463530373b9b6be2 ParentId: |f642c290-463530373b9b6be2.f642c291_ RequestId: 80002a50-0000-e000-b63f-84710c7967bb RequestPath: /databaseResource/custom ActionId: 5bd8da64-f504-4e8c-be41-0509560c017f ActionName: api.Controllers.CustomResource.Custom (api) Value cannot be null. (Parameter 'resourceType') Exception: System.ArgumentNullException: Value cannot be null. (Parameter 'resourceType') at void ArgumentNullException.Throw(string paramName) at void JsonApiDotNetCore.ArgumentGuard.NotNull<T>(T value, string parameterName) in //src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs:line 15 at IncludeExpression JsonApiDotNetCore.Queries.Parsing.IncludeParser.Parse(string source, ResourceType resourceType) in //src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs:line 27 at IncludeExpression JsonApiDotNetCore.QueryStrings.IncludeQueryStringParameterReader.GetInclude(string parameterValue) in //src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs:line 65 at void JsonApiDotNetCore.QueryStrings.IncludeQueryStringParameterReader.Read(string parameterName, StringValues parameterValue) in //src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs:line 52 at void JsonApiDotNetCore.QueryStrings.QueryStringReader.ReadAll(DisableQueryStringAttribute disableQueryStringAttribute) in //src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs:line 60 at async Task JsonApiDotNetCore.Middleware.AsyncQueryStringActionFilter.OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) in //src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs:line 29 at async Task Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()+Awaited(?) at void Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context) at Task Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted) at Task Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync() at async Task Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeNextExceptionFilterAsync()+Awaited(?)

and following http 500 response body:

{
    "links": {
        "self": "/databaseResource/custom?include=otherDatabaseResource"
    },
    "errors": [
        {
            "id": "be25d210-46fe-4505-a457-b2f3383a7d04",
            "status": "500",
            "title": "An unhandled error occurred while processing this request.",
            "detail": "Value cannot be null. (Parameter 'resourceType')"
        }
    ],
    "included": []
}

STEPS TO REPRODUCE

Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<DatabaseContext>(options => {
    var connectionString = builder.Configuration.GetConnectionString("Database");
    options.UseSqlServer(connectionString);
#if DEBUG
    options.UseLoggerFactory(LoggerFactory.Create(builder => builder.AddConsole()));
    options.EnableSensitiveDataLogging();
    options.EnableDetailedErrors();
#endif 
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddScheme<AuthenticationSchemeOptions, CustomAuthenticationHandler>(JwtBearerDefaults.AuthenticationScheme, (o) => { });

builder.Services.AddAuthorization();

builder.Services.AddScoped<CustomAuthenticationFunctionHandler>();

builder.Services.AddJsonApi<myManitouDatabaseContext>(options => {
    options.DefaultPageSize = new(10);
    options.MaximumPageSize = new(1000);
    options.IncludeTotalResourceCount = true;
    options.ResourceLinks = LinkTypes.Pagination | LinkTypes.Self;
    options.IncludeRequestBodyInErrors = true;
    options.SerializerOptions.WriteIndented = true;
    options.UseRelativeLinks = true;
}, discovery => discovery.AddCurrentAssembly());


app.UseRouting();

app.UseAuthentication();

app.UseAuthorization();

app.UseJsonApi();

app.MapControllers();

app.Run();

CustomAuthenticationHandler.cs

    public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> {
        private readonly CustomAuthenticationFunctionHandler _customAuthenticationFunctionHandler;
        private readonly IExceptionHandler _exceptionHandler;
        private readonly IJsonApiWriter _jsonApiWriter;
        private readonly IHttpContextAccessor _httpContextAccessor;

        public CustomAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, CustomAuthenticationFunctionHandler customAuthenticationFunctionHandler, IExceptionHandler exceptionHandler, IJsonApiWriter jsonApiWriter, IHttpContextAccessor httpContextAccessor)
            : base(options, logger, encoder, clock) {
            _customAuthenticationFunctionHandler = customAuthenticationFunctionHandler;
            _exceptionHandler = exceptionHandler;
            _jsonApiWriter = jsonApiWriter;
            _httpContextAccessor = httpContextAccessor;
        }

        protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
            return _customAuthenticationFunctionHandler.HandleAuthenticateAsync(Context);
        }

        /// <inheritdoc/>
        protected override Task HandleChallengeAsync(AuthenticationProperties properties) {
            IReadOnlyList<ErrorObject> errors = _exceptionHandler.HandleException(new UnauthorizedException());

            Response.StatusCode = (int)ErrorObject.GetResponseStatusCode(errors);
            _jsonApiWriter.WriteAsync(errors, _httpContextAccessor.HttpContext!);

            return Task.CompletedTask;
        }
    }
}

CustomAuthenticationFunctionHandler.cs

    public class CustomAuthenticationFunctionHandler {
        private const string BEARER_PREFIX = "Bearer ";

        public CustomAuthenticationFunctionHandler() { }

        public Task<AuthenticateResult> HandleAuthenticateAsync(HttpRequest request) =>
            HandleAuthenticateAsync(request.HttpContext);

        public async Task<AuthenticateResult> HandleAuthenticateAsync(HttpContext context) {
            if (!context.Request.Headers.ContainsKey("Authorization")) {
                return AuthenticateResult.NoResult();
            }

            string? bearerToken = context.Request.Headers["Authorization"];

            if (bearerToken == null || !bearerToken.StartsWith(BEARER_PREFIX)) {
                return AuthenticateResult.Fail("Invalid scheme.");
            }

            string token = bearerToken.Substring(BEARER_PREFIX.Length);

            if(token == "yomama") {
                return AuthenticateResult.Success(await CreateAuthenticationTicket());
            }

            return AuthenticateResult.Fail(ex);
        }

        private async Task<AuthenticationTicket> CreateAuthenticationTicket() {
            ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(new List<ClaimsIdentity>()
            {
                new ClaimsIdentity(await ToClaims(), nameof(ClaimsIdentity))
            });

            return new AuthenticationTicket(claimsPrincipal, JwtBearerDefaults.AuthenticationScheme);
        }

        private async Task<IEnumerable<Claim>> ToClaims() {
            return new List<Claim>
            {
                new Claim("SOME_INFO", "ok")
            };
        }
    }
}

UnauthorizedException.cs

namespace JsonApiDotNetCore.Errors;
public sealed class UnauthorizedException : JsonApiException {
    public UnauthorizedException()
        : base(new ErrorObject(HttpStatusCode.Unauthorized) {
            Title = "The user should be authenticated to perform this action."
        }) {
    }
}

VERSIONS USED

  • JsonApiDotNetCore version: 5.5.0
  • ASP.NET Core version: .net 7
  • Entity Framework Core version: 7.0.14
  • Database provider: EntityFrameworkCore.SqlServer

I'm not near a computer, so I cannot debug. But I suspect it has do with the order in which things execute. Apparently, the JsonApiMiddleware hasn't yet executed when query string parameters are being parsed, which is odd. Can you include the full Program.cs that includes JsonApiDotNetCore usage?

Hello @bkoelman,thanks for your answer. I just updated my message so you can check the program.cs including the JsonApiDotNetCore usage.

Thanks. Can you try moving UseJsonApi directly below UseRouting to see if that helps? Otherwise, build against the source and add breakpoints in JsonApiMiddleware and QueryStringReader, along with your authentication handler. It may be the case that the authentication middleware terminates the pipeline instead of delegating to the next handler (ours). Query string parsing is wired up at https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs#L112, you may want to inspect the MVC filter order there too.

Thanks! Moving the UseJsonApi below UseRouting works.

The Program.cs now looks like:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<DatabaseContext>(options => {
    var connectionString = builder.Configuration.GetConnectionString("Database");
    options.UseSqlServer(connectionString);
#if DEBUG
    options.UseLoggerFactory(LoggerFactory.Create(builder => builder.AddConsole()));
    options.EnableSensitiveDataLogging();
    options.EnableDetailedErrors();
#endif 
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddScheme<AuthenticationSchemeOptions, CustomAuthenticationHandler>(JwtBearerDefaults.AuthenticationScheme, (o) => { });

builder.Services.AddAuthorization();

builder.Services.AddScoped<CustomAuthenticationFunctionHandler>();

builder.Services.AddJsonApi<myManitouDatabaseContext>(options => {
    options.DefaultPageSize = new(10);
    options.MaximumPageSize = new(1000);
    options.IncludeTotalResourceCount = true;
    options.ResourceLinks = LinkTypes.Pagination | LinkTypes.Self;
    options.IncludeRequestBodyInErrors = true;
    options.SerializerOptions.WriteIndented = true;
    options.UseRelativeLinks = true;
}, discovery => discovery.AddCurrentAssembly());


app.UseRouting();

app.UseJsonApi();

app.UseAuthentication();

app.UseAuthorization();

app.MapControllers();

app.Run();

I will close the issue.

Great to hear you’re unblocked. However it seems wrong that your auth implementation terminates the middleware pipeline prematurely. You may run into more oddities because of that.

It's all thanks to you. To avoid any oddities what should I do?

Well, the middleware execution order is pretty critical to any app. What you have right now is that JSON:API requests execute before users are authorized. So familiarize yourself with the basic concepts by reading the ASP.NET docs on the middleware pipeline, filters and auth, and debug to understand what is going on. I don't know know where you got your idea for this custom code, but it probably needs to be handled in a different way.