graphql-dotnet/server

Duende Identity Server 4 / GraphQl Authorize - Always error 403

StefanKoenigMUC opened this issue · 16 comments

Hi,

somehow i'm not able to get authentication working properly - I'm using DuendeIdentityServer4 with JWT Tokens (this is working proper with SignalR and Rest APIs). Now I tried to include GraphQL to that process as well, but I don't achieve any success there.

All queries (with valid JWT Tokens) returning 401/403 with those logs:

[2023-07-04T11:21:08.209] [INFO] [Microsoft.AspNetCore.Hosting.Diagnostics] [] [] [Request starting HTTP/1.1 POST http://localhost:5000/graphql application/json 615  ] [0] [Protocol=HTTP/1.1, Method=POST, ContentType=application/json, ContentLength=615, Scheme=http, Host=localhost:5000, Path=/graphql, EventId=1]
[2023-07-04T11:21:08.223] [DEBUG] [Microsoft.AspNetCore.Cors.Infrastructure.CorsService] [] [] [The request has an origin header: 'http://localhost:5000'.  ] [0] [origin=http://localhost:5000, EventId=2, EventName=RequestHasOriginHeader]
[2023-07-04T11:21:08.242] [INFO] [Microsoft.AspNetCore.Cors.Infrastructure.CorsService] [] [] [CORS policy execution successful.  ] [0] [EventId=4, EventName=PolicySuccess]
[2023-07-04T11:21:08.253] [DEBUG] [Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware] [] [] [POST requests are not supported  ] [0] [Method=POST, EventId=1, EventName=MethodNotSupported]
[2023-07-04T11:21:08.272] [INFO] [Microsoft.AspNetCore.Cors.Infrastructure.CorsMiddleware] [] [] [No CORS policy found for the specified request.  ] [0] [EventId=10, EventName=NoCorsPolicyFound]
[2023-07-04T11:21:08.282] [DEBUG] [Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler] [] [] [AuthenticationScheme: idsrv was successfully authenticated.  ] [0] [AuthenticationScheme=idsrv, EventId=8, EventName=AuthenticationSchemeAuthenticated]
[2023-07-04T11:21:08.282] [DEBUG] [Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler] [stefan.koenig] [] [AuthenticationScheme: idsrv was successfully authenticated.  ] [0] [AuthenticationScheme=idsrv, EventId=8, EventName=AuthenticationSchemeAuthenticated]
[2023-07-04T11:21:08.282] [DEBUG] [Microsoft.AspNetCore.Routing.Matching.DfaMatcher] [stefan.koenig] [] [No candidates found for the request path '/graphql'  ] [0] [Path=/graphql, EventId=1000, EventName=CandidatesNotFound]
[2023-07-04T11:21:08.297] [DEBUG] [Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware] [stefan.koenig] [] [Request did not match any endpoints  ] [0] [EventId=2, EventName=MatchFailure]
[2023-07-04T11:21:08.297] [DEBUG] [Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler] [stefan.koenig] [] [AuthenticationScheme: idsrv was successfully authenticated.  ] [0] [AuthenticationScheme=idsrv, EventId=8, EventName=AuthenticationSchemeAuthenticated]
[2023-07-04T11:21:08.297] [DEBUG] [Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware] [stefan.koenig] [] [POST requests are not supported  ] [0] [Method=POST, EventId=1, EventName=MethodNotSupported]
[2023-07-04T11:21:08.317] [DEBUG] [Microsoft.AspNetCore.ResponseCompression.ResponseCompressionProvider] [stefan.koenig] [] [The response will be compressed with 'br'.  ] [0] [provider=br, EventId=8, EventName=CompressWith]
[2023-07-04T11:21:08.332] [DEBUG] [Microsoft.AspNetCore.Server.Kestrel.Connections] [stefan.koenig] [] [Connection id "0HMRSDPUR99KJ" completed keep alive response.  ] [0] [ConnectionId=0HMRSDPUR99KJ, EventId=9, EventName=ConnectionKeepAlive]
[2023-07-04T11:21:08.332] [INFO] [Microsoft.AspNetCore.Hosting.Diagnostics] [stefan.koenig] [] [Request finished HTTP/1.1 POST http://localhost:5000/graphql application/json 615 - 403 - application/json;+charset=utf-8 115.5603ms  ] [0] [ElapsedMilliseconds=115.5603, StatusCode=403, ContentType=application/json; charset=utf-8, Protocol=HTTP/1.1, Method=POST, Scheme=http, Host=localhost:5000, Path=/graphql, EventId=2]
[2023-07-04T11:21:08.332] [DEBUG] [Microsoft.AspNetCore.Server.Kestrel] [] [] [Connection id "0HMRSDPUR99KJ", Request id "0HMRSDPUR99KJ:00000001": started reading request body.  ] [0] [ConnectionId=0HMRSDPUR99KJ, TraceIdentifier=0HMRSDPUR99KJ:00000001, EventId=25, EventName=RequestBodyStart]
[2023-07-04T11:21:08.332] [DEBUG] [Microsoft.AspNetCore.Server.Kestrel] [] [] [Connection id "0HMRSDPUR99KJ", Request id "0HMRSDPUR99KJ:00000001": done reading request body.  ] [0] [ConnectionId=0HMRSDPUR99KJ, TraceIdentifier=0HMRSDPUR99KJ:00000001, EventId=26, EventName=RequestBodyDone]

I did it like that:

Startup.cs

  services.AddGraphQL(builder => builder
         .AddUserContextBuilder(httpContext => new UserContext(httpContext.User))
         .AddAuthorizationRule()
         .AddSchema<MySchema>()
         .ConfigureExecutionOptions(options =>
         {
             options.EnableMetrics = Environment.IsDevelopment();
             options.UnhandledExceptionDelegate = ctx =>
             {
                 logger.Error($"Uncaught GraphQl Exception -> {ctx.ErrorMessage}", ctx.Exception);
                 return Task.CompletedTask;
             };
         })
         .AddNewtonsoftJson() // For everything else
         .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true)
     );
        app.UseGraphQL("/graphql", config =>
        {
            config.AuthorizedRoles.Add("role.manager.access");
            config.AuthorizationRequired = true;
        });
        app.UseGraphQLAltair();
        app.UseGraphQLPlayground();

My Root Query is annotated with

[Authorize(Policy = "user.api..manager")]

Do you have any idea what I'm doing wrong?

Which GraphQL nuget packages do you have installed and what versions?

Is the default authentication scheme the JWT bearer scheme?

    protected virtual void AddAuthentication(IServiceCollection services)
    {
        var tokenValidationParameters = new TokenValidationParameters()
        {
            ValidIssuer = Configuration["IdentityServer:TokenValidationParameters:ValidIssuer"],
            ValidAudiences = Configuration.GetSection("IdentityServer:TokenValidationParameters:ValidAudiences").Get<List<string>>(),
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["IdentityServer:TokenValidationParameters:Secret"])),
            NameClaimType = Configuration["IdentityServer:TokenValidationParameters:NameClaimType"],
            RoleClaimType = Configuration["IdentityServer:TokenValidationParameters:RoleClaimType"],
        };

        var jwtSecurityTokenHandler = new JwtSecurityTokenHandler
        {
            InboundClaimTypeMap = new Dictionary<string, string>()
        };

        IdentityModelEventSource.ShowPII = true;

        services.AddAuthentication().AddJwtBearer(options =>
        {
            if (JwtBackChannelHandler != null)
            {
                options.BackchannelHttpHandler = JwtBackChannelHandler;
            }

            options.Authority = Configuration["IdentityServer:BearerOptions:Authority"];
            options.Audience = Configuration["IdentityServer:BearerOptions:Audience"];
            options.IncludeErrorDetails = Configuration.GetValue<bool>("IdentityServer:BearerOptions:IncludeErrorDetails");
            options.SaveToken = Configuration.GetValue<bool>("IdentityServer:BearerOptions:SaveToken");
            options.RequireHttpsMetadata = Configuration.GetValue<bool>("IdentityServer:BearerOptions:RequireHttpsMetadata");
            options.SecurityTokenValidators.Clear();
            options.SecurityTokenValidators.Add(jwtSecurityTokenHandler);
            options.TokenValidationParameters = tokenValidationParameters;
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    if (context.Request.Path.Value.StartsWith("/hubs/", StringComparison.InvariantCulture) && context.Request.Query.TryGetValue("access_token", out StringValues token))
                    {
                        context.Token = token;
                    }

                    return Task.CompletedTask;
                },
                OnAuthenticationFailed = context =>
                {
                    var te = context.Exception;

                    logger.Error(te, $"Error during jwt authentication for prinicipal {context.Principal}");

                    return Task.CompletedTask;
                }
            };
        });
    }

that's how jwt authentication is configured (according to https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/7.0/default-authentication-scheme I should not provide an default value anymore?)

installed packages:

		<PackageReference Include="Duende.IdentityServer" Version="6.3.2" />
		<PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="6.3.2" />
		<PackageReference Include="GraphQL.Client" Version="6.0.0" />
		<PackageReference Include="GraphQL.Client.Abstractions" Version="6.0.0" />
		<PackageReference Include="GraphQL.Client.LocalExecution" Version="6.0.0" />
		<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.0.0" />
		<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.0.0" />
		<PackageReference Include="GraphQL.NewtonsoftJson" Version="7.0.0" />
		<PackageReference Include="GraphQL.Server.All" Version="7.0.0" />
		<PackageReference Include="GraphQL.SystemTextJson" Version="7.0.0" />

yesterday I tried with 7.5 (but same result with 7.0.0 and 7.5.0).

Thanks for your reply!

played further around, but its not working at all. At the current state I'm always getting successfully authenticated requests - even when no jwt token is provided.

Is there maybe a working example with IdentityServer4 available? I'm confident there is an error on my side, but I'm not able to figure it out.

What also boggles my mind a bit - in

            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    if (context.Request.Path.Value.StartsWith("/hubs/", StringComparison.InvariantCulture) && context.Request.Query.TryGetValue("access_token", out StringValues token))
                    {
                        context.Token = token;
                    }

                    return Task.CompletedTask;
                },
                OnAuthenticationFailed = context =>
                {
                    var te = context.Exception;

                    logger.Error(te, $"Error during jwt authentication for prinicipal {context.Principal}");

                    return Task.CompletedTask;
                }
            };

you may see how I integrated SignalR into Identity Server. But already that function is not called when a GraphQl query is submitted. So it seems, the whole identity server part is skipped?

Since I see you're using the GraphQL Server authentication library, and specifically the newer implementation, I'm going to move this to the applicable repo.

I wrote the authorization implementation you've been trying to use. Basically, the GraphQL middleware uses the identity of the HttpContext.User property to obtain a ClaimsPrincipal instance to check authentication and role membership against. This is set by the call to ASP.NET Core's app.UseAuthentication() which must appear prior to the app.UseGraphQL() call.

The implementation is very simple (and can easily be overridden with a custom implementation) - see:

With JWT bearer authentication, there's two specific gotchyas that you have to look out for:

First, there is no setting to control the authentication scheme used. I believe that in theory you can have two different authentication schemes, say JWT bearer and cookie, and only one will be default. The default will be used to set HttpContext.User under normal circumstances. I'm not sure how to tell ASP.NET Core to use a different authentication scheme when using the middleware, which is why I don't have a property to set the auth scheme. It may be that if you use endpoint mapping, you can set the auth scheme of the endpoint within the routing config, and ASP.NET Core will properly set the HttpContext.User property before the middleware executes. This is similar to how you can program specific CORS rules to take effect.

Second, the JWT bearer claim names differ from those used within C# (assuming of course that you are passing the roles within the token itself). Here's ChatGPT's explanation:

In a JWT (JSON Web Token), the claim name commonly used to represent roles is "roles". The "roles" claim is used to specify the roles or permissions associated with a user.

However, in ASP.NET Core, the claim name for roles is "http://schemas.microsoft.com/ws/2008/06/identity/claims/role". This is the default claim name used by ASP.NET Core's authentication and authorization mechanisms to represent user roles.

It's worth noting that while "roles" is a widely used claim name in JWT, ASP.NET Core follows a more specific claim name to ensure compatibility with other systems and standards.

Normally this remapping is handled automatically by the JWT handler. But I did notice that it appears you've removed the default mappings, and so it's possible that this is an issue.

        var jwtSecurityTokenHandler = new JwtSecurityTokenHandler
        {
            InboundClaimTypeMap = new Dictionary<string, string>()
        };

Another issue I've seen is that the [Authorize] attribute exists within the ASP.NET Core and GraphQL namespaces. When tagging your type-first schema definitions, you need to use the GraphQL.NET AuthorizeAttribute or else it won't work as expected.

I do use JWT bearer extensively for my own servers, but I don't use IdentityServer. Hopefully I can still be of help. I suggest reviewing the JWT sample we have here:

And here is the sample for endpoint middleware:

This is what I would suggest as debugging steps:

  1. Remove [Authorize] attribute from the Query type
  2. Remove role check from the UseGraphQL call
  3. Try to connect and perform a simple query such as { __typename } with only the RequireAuthentication property set
  4. If it works, add test resolver and inspect the IResolveFieldContext.User property, checking if the roles were set properly, etc, otherwise:
  5. Ensure that UseAuthorization occurs before UseGraphQL
  6. Try setting the default authorization scheme; if that works, revert and see if you can configure the authorization scheme when using endpoint routing; if so, set up GraphQL through endpoint routing with a configured authorization scheme
  7. If all else fails, perhaps you can do something like this (which is what I do in one of my apps):
            app.MapWhen(
                context => context.Request.Path.Equals(path) &&
                    (context.WebSockets.IsWebSocketRequest || HttpMethods.IsPost(context.Request.Method) || HttpMethods.IsOptions(context.Request.Method)),
                b => {
                    // use JWT authentication, and ignore other authentication
                    b.Use(next => (RequestDelegate)(async context => {
                        //-- you'd think this would work: --
                        //var user = context.User.Identities.FirstOrDefault(x => x.AuthenticationType == JwtBearerDefaults.AuthenticationScheme);
                        //context.User = new ClaimsPrincipal(user ?? new ClaimsIdentity());

                        //-- and you'd think this would work: --
                        //var auth = context.RequestServices.GetRequiredService<IAuthenticationService>();
                        //await auth.AuthenticateAsync(context, JwtBearerDefaults.AuthenticationScheme);

                        //-- but they don't, so we do this --
                        context.User = new ClaimsPrincipal(new ClaimsIdentity());
                        var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
                        var authToken = authHeader?.StartsWith("Bearer ") ?? false ? authHeader[7..] : null;
                        if (authToken != null) {
                            var validationParameters = context.RequestServices.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<JwtBearerOptions>>()
                                .Get(JwtBearerDefaults.AuthenticationScheme)
                                .TokenValidationParameters;
                            var handler = new JwtSecurityTokenHandler();
                            try {
                                context.User = handler.ValidateToken(authToken, validationParameters, out _);
                            } catch { }
                        }

                        await next(context).ConfigureAwait(false);
                    }));
                    // apply GraphQL CORS policy
                    b.UseCors("GraphQL");
                    // call graphql
                    b.UseGraphQL(path, opts => {
                        opts.AuthorizationRequired = true;
                        opts.AuthorizedRoles.Add("myRole");
                    });
                });

If you have any ideas how we can implement an AuthenticationScheme property within the GraphQL middleware, so that ASP.NET Core automatically sets the HttpContext.User property properly for the JWT Bearer authentication scheme, please let me know. Obviously the above patch is quite specific to my own use case.

image

just had a quick test right now, looks good! :-) will have a further look for implementation tomorrow. Big big thanks so far!

Short update, have now my own middleware in place:

    protected override ValueTask<bool> HandleAuthorizeAsync(HttpContext context, RequestDelegate next)
    {
        var resultTask = context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);

        resultTask.Wait();

        var result = resultTask.Result;

        if(result.Succeeded)
        {
            context.User = result.Principal;
        }

        return base.HandleAuthorizeAsync(context, next);
    }

it seems to be working now, not the best of all solutions. What confuses me at the moment

        var jwtSecurityTokenHandler = new JwtSecurityTokenHandler
        {
            InboundClaimTypeMap = new Dictionary<string, string>()
        };

is required for me, otherwise the claim cannot be found due to:

if (user.IsInRole(role))

is always looking for the "role" claim, the default InBoundClaimTypeMap is mapping the jwt "role" property to ClaimTypes.Role, which does not match for the "longer" role. But that's probably dug somewhere else within the application (need to check that).

So far its now working (maybe not in the best way - but takes the pressure out for now; will have a look at your described steps later today)

Excellent! Two comments: HandleAuthorizeAsync is any async method and so you can call AuthenticateAsync with await instead of .Wait and Result for true async flow.

Secondly, if you utilize subscriptions, then you might need to override HandleAuthorizeWebSocketConnectionAsync with basically the same code. I would as a matter of principle even I was not yet using subscriptions.

Perhaps I can add this functionality into GraphQL.NET Server 7.6

thanks - changed it accordingly. Subscriptions are not yet in use (but will add it there as well)

thanks a lot for your help. As said, will try your steps described above as well - maybe I'll get it to work as well.

Here is my prototype change (going to test it in my own 'fork' first before making the same PR here):

Shane32/GraphQL.AspNetCore3#59

Works for my server. Look for an updated version of GraphQL.NET Server 7 shortly with the new property. It seems that my old notes shown here were actually mostly correct, but it didn't follow through setting the context.User property to the result.

//-- and you'd think this would work: --
//var auth = context.RequestServices.GetRequiredService<IAuthenticationService>();
//await auth.AuthenticateAsync(context, JwtBearerDefaults.AuthenticationScheme);

See the PR for the proposed implementation. My server code now looks like this, which is a substantial improvement:

                endpoints.MapGraphQL(
                    "/graphql",
                    opts => {
                        opts.HandleGet = false;
                        // GraphQL requests will ignore cookie authentication and use JWT authentication only
                        opts.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme);
                        opts.AuthorizationRequired = true;
                        opts.AuthorizedRoles.Add("myRole");
                    })
                    .RequireCors("GraphQL");

Thanks so much for your work and help! Would have taken me ages to figure it out by myself.