diff --git a/README.md b/README.md index e539b3c3..a325246a 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,28 @@ Note that `InvokeAsync` will execute even if the protocol is disabled in the opt disabling `HandleGet` or similar; `HandleAuthorizeAsync` and `HandleAuthorizeWebSocketConnectionAsync` will not. +#### Authentication schemes + +By default the role and policy requirements are validated against the current user as defined by +`HttpContext.User`. This is typically set by ASP.NET Core's authentication middleware and is based +on the default authentication scheme set during the call to `AddAuthentication` in `Startup.cs`. +You may override this behavior by specifying a different authentication scheme via the `AuthenticationSchemes` +option. For instance, if you wish to authenticate using JWT authentication when Cookie authentication is +the default, you may specify the scheme as follows: + +```csharp +app.UseGraphQL("/graphql", config => +{ + // specify a specific authentication scheme to use + config.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme); +}); +``` + +This will overwrite the `HttpContext.User` property when handling GraphQL requests, which will in turn +set the `IResolveFieldContext.User` property to the same value (unless being overridden via an +`IWebSocketAuthenticationService` implementation as shown above). So both endpoint authorization and +field authorization will perform role and policy checks against the same authentication scheme. + ### UI configuration There are four UI middleware projects included; Altair, GraphiQL, Playground and Voyager. diff --git a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs index f2c79e47..c314630b 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs @@ -1,5 +1,6 @@ #pragma warning disable CA1716 // Identifiers should not match keywords +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Primitives; using MediaTypeHeaderValueMs = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; @@ -270,6 +271,8 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne /// protected virtual async ValueTask HandleAuthorizeAsync(HttpContext context, RequestDelegate next) { + await SetHttpContextUserAsync(context); + var success = await AuthorizationHelper.AuthorizeAsync( new AuthorizationParameters<(GraphQLHttpMiddleware Middleware, HttpContext Context, RequestDelegate Next)>( context, @@ -282,6 +285,26 @@ protected virtual async ValueTask HandleAuthorizeAsync(HttpContext context return !success; } + /// + /// If any authentication schemes are defined, set the property. + /// + private async ValueTask SetHttpContextUserAsync(HttpContext context) + { + if (_options.AuthenticationSchemes.Count > 0) + { + ClaimsPrincipal? newPrincipal = null; + foreach (var scheme in _options.AuthenticationSchemes) + { + var result = await context.AuthenticateAsync(scheme); + if (result != null && result.Succeeded) + { + newPrincipal = SecurityHelper.MergeUserPrincipal(newPrincipal, result.Principal); + } + } + context.User = newPrincipal ?? new ClaimsPrincipal(new ClaimsIdentity()); + } + } + /// /// Perform authorization, if required, and return if the /// request was handled (typically by returning an error message). If @@ -291,8 +314,11 @@ protected virtual async ValueTask HandleAuthorizeAsync(HttpContext context /// the WebSocket connection during the ConnectionInit message. Authorization checks for /// WebSocket connections occur then, after authorization has taken place. /// - protected virtual ValueTask HandleAuthorizeWebSocketConnectionAsync(HttpContext context, RequestDelegate next) - => new(false); + protected virtual async ValueTask HandleAuthorizeWebSocketConnectionAsync(HttpContext context, RequestDelegate next) + { + await SetHttpContextUserAsync(context); + return false; + } /// /// Handles a single GraphQL request. diff --git a/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs b/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs index 5e328360..30196365 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs @@ -84,6 +84,12 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions /// public bool ReadExtensionsFromQueryString { get; set; } = true; + /// + /// Gets or sets a list of the authentication schemes the authentication requirements are evaluated against. + /// When no schemes are specified, the default authentication scheme is used. + /// + public List AuthenticationSchemes { get; set; } = new(); + /// /// /// HTTP requests return 401 Forbidden when the request is not authenticated. diff --git a/src/Transports.AspNetCore/SecurityHelper.cs b/src/Transports.AspNetCore/SecurityHelper.cs new file mode 100644 index 00000000..5d78cedd --- /dev/null +++ b/src/Transports.AspNetCore/SecurityHelper.cs @@ -0,0 +1,73 @@ +// source: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/SecurityHelper/SecurityHelper.cs +// permalink: https://github.com/dotnet/aspnetcore/blob/8b2fd3f7a3b3e18afc6f63c4a494cc733dcced64/src/Shared/SecurityHelper/SecurityHelper.cs +// retrieved: 2023-07-05 + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +/* + +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +namespace GraphQL.Server.Transports.AspNetCore; + +/// +/// Helper code used when implementing authentication middleware +/// +internal static class SecurityHelper +{ + /// + /// Add all ClaimsIdentities from an additional ClaimPrincipal to the ClaimsPrincipal + /// Merges a new claims principal, placing all new identities first, and eliminating + /// any empty unauthenticated identities from context.User + /// + /// The containing existing . + /// The containing to be added. + public static ClaimsPrincipal MergeUserPrincipal(ClaimsPrincipal? existingPrincipal, ClaimsPrincipal? additionalPrincipal) + { + // For the first principal, just use the new principal rather than copying it + if (existingPrincipal == null && additionalPrincipal != null) + { + return additionalPrincipal; + } + + var newPrincipal = new ClaimsPrincipal(); + + // New principal identities go first + if (additionalPrincipal != null) + { + newPrincipal.AddIdentities(additionalPrincipal.Identities); + } + + // Then add any existing non empty or authenticated identities + if (existingPrincipal != null) + { + newPrincipal.AddIdentities(existingPrincipal.Identities.Where(i => i.IsAuthenticated || i.Claims.Any())); + } + return newPrincipal; + } +} diff --git a/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt index ad39d831..6be79fab 100644 --- a/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -111,6 +111,7 @@ namespace GraphQL.Server.Transports.AspNetCore public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions { public GraphQLHttpMiddlewareOptions() { } + public System.Collections.Generic.List AuthenticationSchemes { get; set; } public bool AuthorizationRequired { get; set; } public string? AuthorizedPolicy { get; set; } public System.Collections.Generic.List AuthorizedRoles { get; set; } diff --git a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt index c47e6e54..a410de5d 100644 --- a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -118,6 +118,7 @@ namespace GraphQL.Server.Transports.AspNetCore public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions { public GraphQLHttpMiddlewareOptions() { } + public System.Collections.Generic.List AuthenticationSchemes { get; set; } public bool AuthorizationRequired { get; set; } public string? AuthorizedPolicy { get; set; } public System.Collections.Generic.List AuthorizedRoles { get; set; } diff --git a/tests/Transports.AspNetCore.Tests/Middleware/AuthorizationTests.cs b/tests/Transports.AspNetCore.Tests/Middleware/AuthorizationTests.cs index 9c5f558a..e54cb5f0 100644 --- a/tests/Transports.AspNetCore.Tests/Middleware/AuthorizationTests.cs +++ b/tests/Transports.AspNetCore.Tests/Middleware/AuthorizationTests.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using GraphQL.Execution; using GraphQL.Server.Transports.AspNetCore.Errors; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; namespace Tests.Middleware; @@ -11,9 +12,14 @@ public class AuthorizationTests : IDisposable { private GraphQLHttpMiddlewareOptions _options = null!; private bool _enableCustomErrorInfoProvider; - private readonly TestServer _server; + private TestServer _server; public AuthorizationTests() + { + _server = CreateServer(); + } + + private TestServer CreateServer(Action? configureServices = null) { var hostBuilder = new WebHostBuilder(); hostBuilder.ConfigureServices(services => @@ -46,6 +52,7 @@ public AuthorizationTests() #if NETCOREAPP2_1 || NET48 services.AddHostApplicationLifetime(); #endif + configureServices?.Invoke(services); }); hostBuilder.Configure(app => { @@ -59,7 +66,7 @@ public AuthorizationTests() _options = opts; }); }); - _server = new TestServer(hostBuilder); + return new TestServer(hostBuilder); } public void Dispose() => _server.Dispose(); @@ -270,6 +277,95 @@ public async Task Authorized_Policy() actual.ShouldBe("""{"data":{"__typename":"Query"}}"""); } + [Fact] + public async Task NotAuthorized_WrongScheme() + { + _server.Dispose(); + _server = CreateServer(services => + { + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // change default scheme to Cookie authentication + }); + _options.AuthorizationRequired = true; + using var response = await PostQueryAsync("{ __typename }", true); // send an authenticated request (with JWT bearer scheme) + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var actual = await response.Content.ReadAsStringAsync(); + actual.ShouldBe(@"{""errors"":[{""message"":""Access denied for schema."",""extensions"":{""code"":""ACCESS_DENIED"",""codes"":[""ACCESS_DENIED""]}}]}"); + } + + [Fact] + public async Task NotAuthorized_WrongScheme_2() + { + _server.Dispose(); + _server = CreateServer(services => + { + services.AddAuthentication().AddCookie(); // add Cookie authentication + }); + _options.AuthorizationRequired = true; + _options.AuthenticationSchemes.Add(CookieAuthenticationDefaults.AuthenticationScheme); // change authentication scheme for GraphQL requests to Cookie (which is not used by the test client) + using var response = await PostQueryAsync("{ __typename }", true); // send an authenticated request (with JWT bearer scheme) + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var actual = await response.Content.ReadAsStringAsync(); + actual.ShouldBe(@"{""errors"":[{""message"":""Access denied for schema."",""extensions"":{""code"":""ACCESS_DENIED"",""codes"":[""ACCESS_DENIED""]}}]}"); + } + + [Fact] + public async Task NotAuthorized_WrongScheme_VerifyUser() + { + bool validatedUser = false; + _server.Dispose(); + _server = CreateServer(services => + { + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // change default scheme to Cookie authentication + services.AddGraphQL(b => b + .ConfigureExecutionOptions(opts => + { + opts.User.ShouldNotBeNull().Identity.ShouldNotBeNull().IsAuthenticated.ShouldBeFalse(); + validatedUser = true; + })); + }); + _options.AuthorizationRequired = false; // disable authorization requirements; we just want to verify that an anonymous user is passed to the execution options + using var response = await PostQueryAsync("{ __typename }", true); // send an authenticated request (with JWT bearer scheme) + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var actual = await response.Content.ReadAsStringAsync(); + actual.ShouldBe(@"{""data"":{""__typename"":""Query""}}"); + validatedUser.ShouldBeTrue(); + } + + [Fact] + public async Task Authorized_DifferentScheme() + { + bool validatedUser = false; + _server.Dispose(); + _server = CreateServer(services => + { + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // change default scheme to Cookie authentication + services.AddGraphQL(b => b.ConfigureExecutionOptions(opts => + { + opts.User.ShouldNotBeNull().Identity.ShouldNotBeNull().IsAuthenticated.ShouldBeTrue(); + validatedUser = true; + })); + }); + _options.AuthorizationRequired = true; + _options.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme); + using var response = await PostQueryAsync("{ __typename }", true); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var actual = await response.Content.ReadAsStringAsync(); + actual.ShouldBe(@"{""data"":{""__typename"":""Query""}}"); + validatedUser.ShouldBeTrue(); + } + + [Fact] + public void SecurityHelperTests() + { + SecurityHelper.MergeUserPrincipal(null, null).ShouldNotBeNull().Identity.ShouldBeNull(); // Note that ASP.NET Core does not return null for anonymous user + var principal1 = new ClaimsPrincipal(new ClaimsIdentity()); // empty identity for primary identity (default for ASP.NET Core) + SecurityHelper.MergeUserPrincipal(null, principal1).ShouldBe(principal1); + var principal2 = new ClaimsPrincipal(new ClaimsIdentity("test1")); // non-empty identity for secondary identity + SecurityHelper.MergeUserPrincipal(principal1, principal2).Identities.ShouldHaveSingleItem().AuthenticationType.ShouldBe("test1"); + var principal3 = new ClaimsPrincipal(new ClaimsIdentity("test2")); // merge two non-empty identities together + SecurityHelper.MergeUserPrincipal(principal2, principal3).Identities.Select(x => x.AuthenticationType).ShouldBe(new[] { "test2", "test1" }); // last one wins + } + private class CustomErrorInfoProvider : ErrorInfoProvider { private readonly AuthorizationTests _authorizationTests; diff --git a/tests/Transports.AspNetCore.Tests/Transports.AspNetCore.Tests.csproj b/tests/Transports.AspNetCore.Tests/Transports.AspNetCore.Tests.csproj index 73947ebe..82b5927a 100644 --- a/tests/Transports.AspNetCore.Tests/Transports.AspNetCore.Tests.csproj +++ b/tests/Transports.AspNetCore.Tests/Transports.AspNetCore.Tests.csproj @@ -15,6 +15,7 @@ +