asp.netauthenticationoauth-2.0jwtauthorize

ASP.NET Set Authorization Policy to one particular AuthenticationScheme


Short version: I want to apply an IAuthorizationRequirement but only for particular users. Or find a different way to solve my problem.

Long version:

I have an Angular SPA application that uses an Asp.Net 5.0 API behind the scenes for CRUD and business logic. My issues are with the API's authentication and authorization.

Two JwtBearer Authentication Schemes

I have a legacy requirement to authenticate and authorize (non human) clients to the same API with a shared secret (which is a Bearer + JWT (signed with the secret) in the Authorization header). So the application can share its data with other applications.

We recently added the ability to authenticate UI users with a third-party using OAuth2/PKCE (it used to be a custom DB authentication). This also comes as a JWT in the Authorization header in the format Bearer + JWT. But it's a different format than our home-grown shared secret-based one.

Enforce a policy that users authenticated by one scheme must be IP-filtered

As a separate effort, I would like the apply a Policy / IAuthorizationRequirement called IPAddressRequirement just to the shared secret authentication. This is so only configured IPs can use that authentication. So, if an authenticated user comes in bearing the Oauth2 JWT, we let them in no matter where they're coming from. If they are authenticated but have the shared secret-signed JWT, we check their IP. This is so, just in case, our shared secret leaks out, some outside bad actor can't use it.

But, the policy is applied to all users

However, the IPAddressRequirement seems to fire on all Authentication schemes. Which would result in UI users authenticated via Oauth2 having their IP addresses checked and blocked unless they are internal (not good).

Another thought: I have the following objects available to me at runtime within the policy check. Is there something I can check for in any of them to determine which AuthenticationScheme was used (while I'm evaluating my policy?).

Code

Here is how I have setup my Policy:

            services.AddAuthorization(options =>
            {
                options.AddPolicy(nameof(IPAddressRequirement), policy =>
                {
                    //I thought this line would limit this policy to just the target scheme, but it did not
                    policy.AddAuthenticationSchemes("SharedSecret");
                    policy.RequireAuthenticatedUser();
                    policy.Requirements.Add(new IPAddressRequirement());
                });
            });

My legacy SharedSecret authentication is created as follows:

                var symmKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(("--- SECRET ---")) );
                services.AddAuthentication()
                        .AddJwtBearer("SharedSecret", options =>
                        {
                            options.TokenValidationParameters = new TokenValidationParameters
                            {
                                ValidateIssuerSigningKey = true,
                                IssuerSigningKey = symmKey,
                                ValidateIssuer = false,
                                ValidateAudience = false
                            };
                        });

The third-party OAuth2 authentication scheme is created as follows:

            services.AddAuthentication()
                    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
                    {
                        options.Authority = "https://example.com";
                        options.TokenValidationParameters = new TokenValidationParameters
                        {
                            ValidateIssuerSigningKey = true,
                            ValidateIssuer = false,
                            ValidateAudience = false
                        };
                    });

My controllers have these Authorize attributes:

    [Authorize(AuthenticationSchemes = "SharedSecret", Policy = nameof(IPAddressRequirement))]
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]

I played around with having these all as one single Authorize attribute as well, didn't seem to have any impact.

Some additional notes:

    public class IPAddressRequirement : IAuthorizationRequirement
    {
        public IPAddressRequirement()
        {
        }
    }

    public class IPAddressRequirementHandler : AuthorizationHandler<IPAddressRequirement>
    {
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly SharedSecretOptions _sharedSecretOptions;

        public IPAddressRequirementHandler(IHttpContextAccessor httpContextAccessor, IOptions<SharedSecretOptions> SharedSecretOptions)
        {
            _httpContextAccessor = httpContextAccessor;
            _sharedSecretOptions = SharedSecretOptions.Value;
        }

        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IPAddressRequirement requirement)
        {
            string authorization = _httpContextAccessor.HttpContext.Request.Headers[HeaderNames.Authorization];
            // If we are dealing with Bearer authentication, check their IP otherwise let them in
            if (!string.IsNullOrEmpty(authorization) && authorization.StartsWith("Bearer "))
            {
                IPAddress incomingIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress;
                //If the client IP address is loopback OR found in the RemoteIPs configuration, let them in.  
                if (IPAddress.IsLoopback(incomingIp)
                        ||
                        _sharedSecretOptions.RemoteIPs
                            .Select(subnet =>
                                IPNetwork.Parse(subnet))
                            .Any(network =>
                                IPNetwork.Contains(network, incomingIp))
                    )
                {
                    context.Succeed(requirement);
                }
                else
                {
                    context.Fail();
                }
            }
            context.Succeed(requirement); //If we failed above for any reason, this succeed doesn't count.  Which is the correct behavior.
            return Task.CompletedTask;
        }
    }

Solution

  • CUSTOM AUTHENTICATION HANDLER

    Sounds like this would be the best fit for your use case, to give you best control and visibility over behaviour. See my example for how to integrate this. You can still use Microsoft's JWT validation within the handler.

    CONTROL OVER THE CLAIMS PRINCIPAL

    The main reason for the custom handler is to enable claims based authorization in the most extensible way, even if some ways of sending in JWTs are legacy. This includes adding your own claims such as trust_level=1 or whatever makes sense for your use case. See my handler class for an example.

    API AUTHORIZATION

    You will then be able to use .NET claims features such as policy based authorization within your API logic, as covered in this Curity code example. This could use custom claims produced from your handler, eg the trust_level value above.

    More importantly than code niceties such as attributes, you can inject claims into your business logic, as in this code, and your business level authorization is then easy to extend.

    MORE ABOUT CLAIMS

    Ideally important claims should be issued within JWT access tokens, where they are digitally verifiable, and it should be possible to include domain specific claims, though this is not always supported. For more info on this topic, see this claims article for how claims are the main authorization mechanism in modern APIs.