Skip to content

Commit

Permalink
Add AuthenticatedSchemes option (#1047)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane32 committed Jul 21, 2023
1 parent a85c0d2 commit 1383231
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 4 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 28 additions & 2 deletions src/Transports.AspNetCore/GraphQLHttpMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -270,6 +271,8 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne
/// </summary>
protected virtual async ValueTask<bool> HandleAuthorizeAsync(HttpContext context, RequestDelegate next)
{
await SetHttpContextUserAsync(context);

var success = await AuthorizationHelper.AuthorizeAsync(
new AuthorizationParameters<(GraphQLHttpMiddleware Middleware, HttpContext Context, RequestDelegate Next)>(
context,
Expand All @@ -282,6 +285,26 @@ protected virtual async ValueTask<bool> HandleAuthorizeAsync(HttpContext context
return !success;
}

/// <summary>
/// If any authentication schemes are defined, set the <see cref="HttpContext.User"/> property.
/// </summary>
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());
}
}

/// <summary>
/// Perform authorization, if required, and return <see langword="true"/> if the
/// request was handled (typically by returning an error message). If <see langword="false"/>
Expand All @@ -291,8 +314,11 @@ protected virtual async ValueTask<bool> HandleAuthorizeAsync(HttpContext context
/// the WebSocket connection during the ConnectionInit message. Authorization checks for
/// WebSocket connections occur then, after authorization has taken place.
/// </summary>
protected virtual ValueTask<bool> HandleAuthorizeWebSocketConnectionAsync(HttpContext context, RequestDelegate next)
=> new(false);
protected virtual async ValueTask<bool> HandleAuthorizeWebSocketConnectionAsync(HttpContext context, RequestDelegate next)
{
await SetHttpContextUserAsync(context);
return false;
}

/// <summary>
/// Handles a single GraphQL request.
Expand Down
6 changes: 6 additions & 0 deletions src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
/// </summary>
public bool ReadExtensionsFromQueryString { get; set; } = true;

/// <summary>
/// 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.
/// </summary>
public List<string> AuthenticationSchemes { get; set; } = new();

/// <inheritdoc/>
/// <remarks>
/// HTTP requests return <c>401 Forbidden</c> when the request is not authenticated.
Expand Down
73 changes: 73 additions & 0 deletions src/Transports.AspNetCore/SecurityHelper.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Helper code used when implementing authentication middleware
/// </summary>
internal static class SecurityHelper
{
/// <summary>
/// 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
/// </summary>
/// <param name="existingPrincipal">The <see cref="ClaimsPrincipal"/> containing existing <see cref="ClaimsIdentity"/>.</param>
/// <param name="additionalPrincipal">The <see cref="ClaimsPrincipal"/> containing <see cref="ClaimsIdentity"/> to be added.</param>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ namespace GraphQL.Server.Transports.AspNetCore
public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions
{
public GraphQLHttpMiddlewareOptions() { }
public System.Collections.Generic.List<string> AuthenticationSchemes { get; set; }
public bool AuthorizationRequired { get; set; }
public string? AuthorizedPolicy { get; set; }
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ namespace GraphQL.Server.Transports.AspNetCore
public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions
{
public GraphQLHttpMiddlewareOptions() { }
public System.Collections.Generic.List<string> AuthenticationSchemes { get; set; }
public bool AuthorizationRequired { get; set; }
public string? AuthorizedPolicy { get; set; }
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }
Expand Down
100 changes: 98 additions & 2 deletions tests/Transports.AspNetCore.Tests/Middleware/AuthorizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<IServiceCollection>? configureServices = null)
{
var hostBuilder = new WebHostBuilder();
hostBuilder.ConfigureServices(services =>
Expand Down Expand Up @@ -46,6 +52,7 @@ public AuthorizationTests()
#if NETCOREAPP2_1 || NET48
services.AddHostApplicationLifetime();
#endif
configureServices?.Invoke(services);
});
hostBuilder.Configure(app =>
{
Expand All @@ -59,7 +66,7 @@ public AuthorizationTests()
_options = opts;
});
});
_server = new TestServer(hostBuilder);
return new TestServer(hostBuilder);
}

public void Dispose() => _server.Dispose();
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

<ItemGroup>
<PackageReference Include="GraphQL.SystemTextJson" Version="$(GraphQLVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.1.*" Condition="'$(TargetFramework)' == 'net48' OR '$(TargetFramework)' == 'netcoreapp2.1'" />
</ItemGroup>

<ItemGroup>
Expand Down

0 comments on commit 1383231

Please sign in to comment.