graphql-platform icon indicating copy to clipboard operation
graphql-platform copied to clipboard

Authorize Roles Does not work

Open jkears opened this issue 2 years ago • 2 comments

Is there an existing issue for this?

  • [X] I have searched the existing issues

Describe the bug

Within a backing GraphQL service, and when adding an Authorize Role Directive, to either the Class or Property level it always returns Unauthorized, even when the Claims Principle contains the Role in question.

Steps to reproduce

Note: We have our own Authorization service that will add Roles to the current Identity based upon the sub claim on the passed in Access Token.

We created a separate AuthorizeRequestExecutorBuilder with the AddAuthorization2 method that we called instead of AddAuthorization. Within AddAuthorization2 we registered our own test IAuthorizationHandler, CheckAuthorizationHandler (below)....

using HotChocolate;
using HotChocolate.AspNetCore.Authorization;
using HotChocolate.Execution.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NextWare.Core.Authorization.Web.Authorization;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Provides extension methods for the GraphQL builder.
/// </summary>
public static class HotChocolateAuthorizeRequestExecutorBuilder2
{
    /// <summary>
    /// Adds the authorization support to the schema.
    /// </summary>
    /// <param name="builder">
    /// The <see cref="IRequestExecutorBuilder"/>.
    /// </param>
    /// <returns>
    /// Returns the <see cref="IRequestExecutorBuilder"/> for chaining in more configurations.
    /// </returns>
    public static IRequestExecutorBuilder AddAuthorization2(
        this IRequestExecutorBuilder builder)
    {
        builder.ConfigureSchema(sb => sb.AddAuthorizeDirectiveType());
        builder.Services.TryAddSingleton<IAuthorizationHandler, CheckAuthorizationHandler>();
        return builder;
    }

    /// <summary>
    /// Adds the authorization support to the schema.
    /// </summary>
    /// <param name="builder">
    /// The <see cref="IRequestExecutorBuilder"/>.
    /// </param>
    /// <returns>
    /// Returns the <see cref="IRequestExecutorBuilder"/> for chaining in more configurations.
    /// </returns>
    [Obsolete("Use AddAuthorization()")]
    public static IRequestExecutorBuilder AddAuthorizeDirectiveType(
        this IRequestExecutorBuilder builder)
        => AddAuthorization2(builder);

    /// <summary>
    /// Adds a custom authorization handler.
    /// </summary>
    /// <param name="builder">
    /// The <see cref="IRequestExecutorBuilder"/>.
    /// </param>
    /// <typeparam name="T">
    /// The custom authorization handler.
    /// </typeparam>
    /// <returns>
    /// Returns the <see cref="IRequestExecutorBuilder"/> for chaining in more configurations.
    /// </returns>
    public static IRequestExecutorBuilder AddAuthorizationHandler<T>(
        this IRequestExecutorBuilder builder)
        where T : class, IAuthorizationHandler
    {
        builder.AddAuthorization2();
        builder.Services.RemoveAll<IAuthorizationHandler>();
        builder.Services.AddSingleton<IAuthorizationHandler, T>();
        return builder;
    }
}

Note: CheckAuthorizationHandler (below) is a clone of 12.8.2's DefaultAuthorizationHandler with the exception of log messages...

using HotChocolate.AspNetCore.Authorization;
using HotChocolate.Resolvers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Security.Principal;

namespace NextWare.Core.Authorization.Web.Authorization
{
    
    public class CheckAuthorizationHandler : HotChocolate.AspNetCore.Authorization.IAuthorizationHandler
    {
        /// <summary>
        /// Authorize current directive using Microsoft.AspNetCore.Authorization.
        /// </summary>
        /// <param name="context">The current middleware context.</param>
        /// <param name="directive">The authorization directive.</param>
        /// <returns>
        /// Returns a value indicating if the current session is authorized to
        /// access the resolver data.
        /// </returns>

        readonly ILogger<CheckAuthorizationHandler> _logger;

        public CheckAuthorizationHandler(ILogger<CheckAuthorizationHandler> logger)
        {
            _logger = logger;
        }
        public async ValueTask<AuthorizeResult> AuthorizeAsync(
            IMiddlewareContext context,
            AuthorizeDirective directive)
        {

             _logger.LogWarning("AuthorizeAsync");
            if (!TryGetAuthenticatedPrincipal(context, out ClaimsPrincipal? principal))
            {
                return AuthorizeResult.NotAuthenticated;
            }

            if (IsInAnyRole(principal, directive.Roles))
            {
                if (NeedsPolicyValidation(directive))
                {
                    return await AuthorizeWithPolicyAsync(
                            context, directive, principal!)
                        .ConfigureAwait(false);
                }
                _logger.LogWarning("AuthorizeAsync  - AuthorizeResult.Allowed");
                return AuthorizeResult.Allowed;
            }

            _logger.LogWarning("AuthorizeAsync  - AuthorizeResult.Not Allowed");

            return AuthorizeResult.NotAllowed;
        }
        private static bool TryGetAuthenticatedPrincipal(
            IMiddlewareContext context,
            [NotNullWhen(true)] out ClaimsPrincipal? principal)
 
        {
            if (context.ContextData.TryGetValue(nameof(ClaimsPrincipal), out var o)
                && o is ClaimsPrincipal p
                && p.Identities.Any(t => t.IsAuthenticated))
            {
                principal = p;
                return true;
            }

            principal = null;
            return false;
        }

        private static bool IsInAnyRole(
            IPrincipal principal,
            IReadOnlyList<string>? roles)
        {
            if (roles == null || roles.Count == 0)
            {
                return true;
            }

            for (var i = 0; i < roles.Count; i++)
            {
                if (principal.IsInRole(roles[i]))
                {
                    return true;
                }
            }

            return false;
        }

        private static bool NeedsPolicyValidation(AuthorizeDirective directive)
        {
            return directive.Roles == null
                   || directive.Roles.Count == 0
                   || !string.IsNullOrEmpty(directive.Policy);
        }

        private static async Task<AuthorizeResult> AuthorizeWithPolicyAsync(
            IMiddlewareContext context,
            AuthorizeDirective directive,
            ClaimsPrincipal principal)
        {
            IServiceProvider services = context.Service<IServiceProvider>();
            IAuthorizationService? authorizeService =
                services.GetService<IAuthorizationService>();
            IAuthorizationPolicyProvider? policyProvider =
                services.GetService<IAuthorizationPolicyProvider>();

            if (authorizeService == null || policyProvider == null)
            {
                // authorization service is not configured so the user is
                // authorized with the previous checks.
                return string.IsNullOrWhiteSpace(directive.Policy)
                    ? AuthorizeResult.Allowed
                    : AuthorizeResult.NotAllowed;
            }

            AuthorizationPolicy? policy = null;

            if ((directive.Roles is null || directive.Roles.Count == 0)
                && string.IsNullOrWhiteSpace(directive.Policy))
            {
                policy = await policyProvider.GetDefaultPolicyAsync()
                    .ConfigureAwait(false);

                if (policy == null)
                {
                    return AuthorizeResult.NoDefaultPolicy;
                }
            }
            else if (!string.IsNullOrWhiteSpace(directive.Policy))
            {
                policy = await policyProvider.GetPolicyAsync(directive.Policy)
                    .ConfigureAwait(false);

                if (policy == null)
                {
                    return AuthorizeResult.PolicyNotFound;
                }
            }

            if (policy is not null)
            {
                AuthorizationResult result =
                    await authorizeService.AuthorizeAsync(principal, context, policy)
                        .ConfigureAwait(false);
                return result.Succeeded ? AuthorizeResult.Allowed : AuthorizeResult.NotAllowed;
            }

            return AuthorizeResult.NotAllowed;
        }
    }
}

We added the following configuration to the TestAuth Service. Note: we are calling AddAuthorization2() from above to ensure we load our test IAuthorizationHandler...

// Configure HotChocolate GraphQL Server
builder.Services.AddGraphQLServer()
  .AddAuthorization2()
  .AddType<ErrorResponse>()
  .AddType<AggregateEvent>()
  .AddType<TestAuthServiceTestAuthAggSM.TestAuthAgg>()
  .AddQueryType().AddTypeExtension<TestAuthAggGraphQLQuery>()
  .AddMutationType().AddTypeExtension<TestAuthAggGraphQLMutation>() 
  .AddMongoDbProjections() / 
  .AddMongoDbFiltering()  
  AddMongoDbSorting().PublishSchemaDefinition(c =>
  {
    c.SetName("nextwaredomaintestauthservice"); // The name of the schema. This name should be unique
    c.PublishToRedis("nextwaredomaingw", sp => sp.GetRequiredService<ConnectionMultiplexer>());
 }
);

The Entity we are querying has the following Authorization Directives...

    [HotChocolate.AspNetCore.Authorization.Authorize]
    [GraphQLName("testAuthService_testAuthAgg")]
    
    public class TestAuthAgg
    {
        
        public string TestData { get; set; } = string.Empty;
       
        [HotChocolate.AspNetCore.Authorization.Authorize(Roles = new string[]{"SensitiveRole"})]
        public string SesitiveData { get; set; } = string.Empty;
 
        public System.Guid? Id { get; set; } = Guid.Empty;
    }

When we query this data without an Access Token we return the following error (which is correct as we are not authenticated)...

Query

{
   testAuthServiceTestAuthAggs {
      testData 
   }
}

Query Results

  "errors": [
    {
      "message": "The current user is not authorized to access this resource.",
      "locations": [
        {
          "line": 2,
          "column": 4
        }
      ],
      "path": [
        "testAuthServiceTestAuthAggs",
        130,
        "testData"
      ],
      "extensions": {
        "code": "AUTH_NOT_AUTHENTICATED",
        "remote": {
          "message": "The current user is not authorized to access this resource.",
          "locations": [
            {
              "line": 1,
              "column": 45
            }
          ],
          "path": [
            "testAuthServiceTestAuthAggs",
            130,
            "testData"
          ],
          "extensions": {
            "code": "AUTH_NOT_AUTHENTICATED"
          }
        },
        "schemaName": "nextwaredomaintestauthservice"
      }
    },

When we add provide an valid Access Token we are able to return all data accept that which is Attributed with the "SensitiveRole"...

Query

{
   testAuthServiceTestAuthAggs {
      testData 
   }
}

Query Results

{
  "data": {
    "testAuthServiceTestAuthAggs": [
      {
        "testData": "dd"
      },
      {

When we query this data and include the SesitiveData field, the following AUTH_NOT_AUTHORIZED is returned.

Query

{
   testAuthServiceTestAuthAggs {
      testData  sesitiveData
   }
}

Query Results

  {
     "message": "The current user is not authorized to access this resource.",
     "locations": [
       {
         "line": 3,
         "column": 16
       }
     ],
     "path": [
       "testAuthServiceTestAuthAggs",
       130,
       "sesitiveData"
     ],
     "extensions": {
       "code": "AUTH_NOT_AUTHORIZED"
     }
   },

We created a copy of the DefaultAuthorizationHandler, and from which we can see that it returns Authorized when we try to access the SesitiveData field.

As can be seen below in our Authorization Handler, when we attempt to access the SensitiveRole the hander resolves to the role claim "SensitiveRole" and returns AuthorizeResult.Allowed. When we do not have the role claim "SensitiveRole" it returns AuthorizeResult.NotAllowed.

image

Why if this is returning AuthorizeResult.Allowed, do we still see the AUTH_NOT_AUTHORIZED ?

Relevant log output

No response

Additional Context?

This is repeatable directly or indirectly through a Gateway. We are tried Apply with both Before and After and still the same issue.

Product

Hot Chocolate

Version

12.8.2

jkears avatar May 20 '22 23:05 jkears

This is also not working for us in 12.10.0-preview.4.

Further, when we tried to use 13.0.0-preview.19, our gateway service is no longer able to resolve HotChocolate.Stitching.Merge as such these additions to configuration of the GraphQL Service had to be removed,

.AddTypeMergeHandler<MergeTypesHandler>(). AddDirectiveMergeHandler<MergeDirectivesHandler>()

and as a result of that, when those are not present we can't run the gateway without receiving the following exceptions ...

The name authorize was already registered by another type. (HotChocolate.Types.DirectiveType)

We are aware that 13.x is going through some major changes and especially in the area of stitching (which will be great), so we are not overly fused that the 13.0.0-preview.19 does not work for us, we just thought we'd try it too see if it resolved our issue.

Please advise as to what we are doing wrong?

jkears avatar May 22 '22 16:05 jkears

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Sep 20 '22 05:09 stale[bot]

I ran into something similar. Not sure if it's related, but in our case, when we created a custom ClaimsIdentity, we had left authenticationType to be null. Using any value there will make it work though. ex:

var identity = new ClaimsIdentity(claims, "Anything");

EricStG avatar Jan 09 '23 21:01 EricStG

Check https://github.com/ChilliCream/graphql-platform/issues/5792

michaelstaib avatar Feb 07 '23 16:02 michaelstaib